Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions connectors/apple-calendar/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -25,13 +25,15 @@
"build": "tsc",
"clean": "rm -rf dist",
"deploy": "plot deploy",
"lint": "plot lint"
"lint": "plot lint",
"test": "vitest run"
},
"dependencies": {
"@plotday/twister": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^2.1.8"
},
"repository": {
"type": "git",
Expand Down
42 changes: 42 additions & 0 deletions connectors/apple-calendar/src/apple-calendar.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
return [`apple-calendar:${uid}`, `icaluid:${uid}`];
}

/**
* A cancellation is "fully in the past" when the cancelled event has already
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
* cancelled occurrence) and flips the thread unread for a meeting that already
* happened — noise, especially when the cancellation syncs in long after the
* fact. Events that have started but not yet finished (ongoing) and future
* events are kept, so the user still learns an upcoming/in-progress meeting
* won't happen.
*
* `start`/`end` are the parsed ICS values (a Date for timed events, a
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
* end (already the end boundary); with no end, a timed start is treated as the
* end (duration unknown) and an all-day start runs to the end of its day.
*/
export function cancellationIsForPastEventFn(
start: Date | string,
end: Date | string | null,
now: Date = new Date()
): boolean {
const toDate = (v: Date | string): Date =>
v instanceof Date ? v : new Date(`${v}T00:00:00Z`);
if (end) return toDate(end) < now;
if (start instanceof Date) return start < now;
const dayEnd = toDate(start);
dayEnd.setUTCDate(dayEnd.getUTCDate() + 1); // all-day end = next-day midnight
return dayEnd < now;
}

type SyncState = {
calendarHref: string;
initialSync: boolean;
Expand DownExpand Up@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {

// Handle cancelled events
if (isCancelled) {
// Drop the cancellation when the event has already ended — a past event's
// cancellation is just noise (and would flip the thread unread for a
// meeting that already happened). Incremental only: initial-sync
// cancellations already returned above.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

const cancelNote = {
key: "cancellation" as const,
content: icsEvent.organizer?.name
Expand DownExpand Up@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
return null;
}

// Drop the cancellation when the occurrence has already ended — bumping
// the master thread for a past occurrence's cancellation is just noise.
if (cancellationIsForPastEventFn(start, end)) {
return null;
}

return {
type: "event",
title: undefined,
Expand Down
48 changes: 48 additions & 0 deletions connectors/apple-calendar/src/cancellation-past.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { cancellationIsForPastEventFn } from "./apple-calendar";

describe("cancellationIsForPastEventFn (apple-calendar)", () => {
const now = new Date("2026-06-29T12:00:00.000Z");

it("treats a timed event that has already ended as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
const end = new Date("2026-06-27T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(true);
});

it("keeps a timed event that has started but not finished", () => {
const start = new Date("2026-06-29T11:00:00.000Z");
const end = new Date("2026-06-29T13:00:00.000Z"); // still running at noon
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("keeps a future timed event", () => {
const start = new Date("2026-07-05T10:00:00.000Z");
const end = new Date("2026-07-05T11:00:00.000Z");
expect(cancellationIsForPastEventFn(start, end, now)).toBe(false);
});

it("treats a timed event with no end whose start is past as past", () => {
const start = new Date("2026-06-27T10:00:00.000Z");
expect(cancellationIsForPastEventFn(start, null, now)).toBe(true);
});

it("treats an all-day event from yesterday as past (DTEND exclusive)", () => {
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
expect(cancellationIsForPastEventFn("2026-06-28", "2026-06-29", now)).toBe(
true
);
});

it("keeps an all-day event happening today (no end, runs to end of day)", () => {
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
expect(cancellationIsForPastEventFn("2026-06-29", null, now)).toBe(false);
});

it("keeps a multi-day all-day event still in progress", () => {
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
expect(cancellationIsForPastEventFn("2026-06-28", "2026-07-01", now)).toBe(
false
);
});
});
10 changes: 10 additions & 0 deletions connectors/apple-calendar/vitest.config.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
resolve: {
// Resolve workspace connector packages from their TypeScript source
// using the @plotday/connector export condition (same as the build path).
conditions: ["@plotday/connector", "default"],
},
test: {},
});
154 changes: 151 additions & 3 deletions connectors/google-calendar/src/sync.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An upcoming event we imported, then cancelled after we started syncing.
// Use a future start/end so this stays a "keep" case regardless of when the
// test runs (a past event would be dropped by the past-cancellation guard).
const futureStart = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const freshCancelled = {
id: "evt-fresh",
iCalUID: "abc@google.com",
status: "cancelled" as const,
created: "2026-06-01T10:00:00.000Z",
updated: "2026-06-26T12:00:00.000Z",
start: { dateTime: "2026-07-01T15:00:00.000Z" },
end: { dateTime: "2026-07-01T16:00:00.000Z" },
start: { dateTime: futureStart.toISOString() },
end: {
dateTime: new Date(
futureStart.getTime() + 60 * 60 * 1000
).toISOString(),
},
summary: "Team sync",
};

Expand DownExpand Up@@ -992,11 +999,14 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
);
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Future occurrence so this stays a "keep" case regardless of run date.
const freshOccurrence = {
id: "evt-occ-fresh",
iCalUID: "master2@google.com",
recurringEventId: "masterid2",
originalStartTime: { dateTime: "2026-07-01T15:00:00.000Z" },
originalStartTime: {
dateTime: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
},
status: "cancelled" as const,
updated: "2026-06-26T12:00:00.000Z",
};
Expand All@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
).toBe(true);
});
});

describe("processCalendarEventsFn — past cancellations", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

const isoDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString();
const dateDaysFromNow = (n: number) =>
new Date(Date.now() + n * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);

it("drops a cancelled standalone event that has already ended", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
// Synced a month ago; the event was imported then cancelled — but it has
// already happened, so surfacing the cancellation only flips unread noise.
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const pastCancelled = {
id: "evt-past",
iCalUID: "past@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1), // recent edit, so the unimported guard keeps it
start: { dateTime: isoDaysFromNow(-2) },
end: { dateTime: isoDaysFromNow(-2) }, // ended ~2 days ago
summary: "Old standup",
};

await processCalendarEventsFn(host, [pastCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled standalone event that has started but not finished", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const ongoingCancelled = {
id: "evt-ongoing",
iCalUID: "ongoing@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(0),
start: { dateTime: isoDaysFromNow(-1) }, // started yesterday
end: { dateTime: isoDaysFromNow(1) }, // ends tomorrow — still running
summary: "Multi-day workshop",
};

await processCalendarEventsFn(host, [ongoingCancelled], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some(
(n) => (n as { key?: string }).key === "cancellation"
)
).toBe(true);
});

it("keeps a cancelled standalone event in the future", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-30));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

const futureCancelled = {
id: "evt-future",
iCalUID: "future@google.com",
status: "cancelled" as const,
created: isoDaysFromNow(-10),
updated: isoDaysFromNow(-1),
start: { dateTime: isoDaysFromNow(7) },
end: { dateTime: isoDaysFromNow(7) },
summary: "Upcoming review",
};

await processCalendarEventsFn(host, [futureCancelled], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(1);
});

it("drops a cancelled recurring occurrence whose occurrence is in the past", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
// already happened. originalStartTime is within the 2-year history window
// and updated is recent (so the unimported guard keeps it), but the
// occurrence is fully in the past — surfacing it just flips unread noise.
const pastOccurrence = {
id: "evt-occ-past",
iCalUID: "recurring@google.com",
recurringEventId: "recurringid",
originalStartTime: { dateTime: isoDaysFromNow(-30) },
status: "cancelled" as const,
updated: isoDaysFromNow(-1),
};

await processCalendarEventsFn(host, [pastOccurrence], calendarId, false);

expect(host.savedLinks.flat()).toHaveLength(0);
});

it("keeps a cancelled all-day recurring occurrence happening today", async () => {
const calendarId = "user@example.com";
const host = makeFakeHost({ calendarId });
host.store.set(`first_sync_at_${calendarId}`, isoDaysFromNow(-60));
vi.stubGlobal("fetch", vi.fn(async () => makeEventsResponse([])));

// An all-day occurrence carries only a date (no time). Today's all-day
// event has started but not finished — it runs until end of day — so its
// cancellation must still surface.
const todayAllDay = {
id: "evt-occ-allday-today",
iCalUID: "recurring-allday@google.com",
recurringEventId: "recurringid-allday",
originalStartTime: { date: dateDaysFromNow(0) },
status: "cancelled" as const,
updated: isoDaysFromNow(0),
};

await processCalendarEventsFn(host, [todayAllDay], calendarId, false);

const saved = host.savedLinks.flat();
expect(saved).toHaveLength(1);
expect(
saved[0].notes?.some((n) =>
(n as { key?: string }).key?.startsWith("cancellation-")
)
).toBe(true);
});
});
Loading
Loading