Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Merge pull request #240 from plotday/fix/calendar-past-cancellation · plotday/plot@73c528a · GitHub
Skip to content

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

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

Commit 73c528a

Browse files
authored
Merge pull request #240 from plotday/fix/calendar-past-cancellation
fix(calendar): skip cancellations for events fully in the past
2 parents e00516f + b1d068a commit 73c528a

11 files changed

Lines changed: 439 additions & 7 deletions

File tree

‎connectors/apple-calendar/package.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,15 @@
2525
"build": "tsc",
2626
"clean": "rm -rf dist",
2727
"deploy": "plot deploy",
28-
"lint": "plot lint"
28+
"lint": "plot lint",
29+
"test": "vitest run"
2930
},
3031
"dependencies": {
3132
"@plotday/twister": "workspace:^"
3233
},
3334
"devDependencies": {
34-
"typescript": "^5.9.3"
35+
"typescript": "^5.9.3",
36+
"vitest": "^2.1.8"
3537
},
3638
"repository": {
3739
"type": "git",

‎connectors/apple-calendar/src/apple-calendar.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,34 @@ function buildEventSources(uid: string | null | undefined): string[] {
4848
return[`apple-calendar:${uid}`,`icaluid:${uid}`];
4949
}
5050

51+
/**
52+
* A cancellation is "fully in the past" when the cancelled event has already
53+
* ended. Surfacing it adds a "cancelled" note (or bumps the master thread for a
54+
* cancelled occurrence) and flips the thread unread for a meeting that already
55+
* happened — noise, especially when the cancellation syncs in long after the
56+
* fact. Events that have started but not yet finished (ongoing) and future
57+
* events are kept, so the user still learns an upcoming/in-progress meeting
58+
* won't happen.
59+
*
60+
* `start`/`end` are the parsed ICS values (a Date for timed events, a
61+
* "YYYY-MM-DD" string for all-day events). An all-day DTEND is the exclusive
62+
* end (already the end boundary); with no end, a timed start is treated as the
63+
* end (duration unknown) and an all-day start runs to the end of its day.
64+
*/
65+
exportfunctioncancellationIsForPastEventFn(
66+
start: Date|string,
67+
end: Date|string|null,
68+
now: Date=newDate()
69+
): boolean{
70+
consttoDate=(v: Date|string): Date=>
71+
vinstanceofDate ? v : newDate(`${v}T00:00:00Z`);
72+
if(end)returntoDate(end)<now;
73+
if(startinstanceofDate)returnstart<now;
74+
constdayEnd=toDate(start);
75+
dayEnd.setUTCDate(dayEnd.getUTCDate()+1);// all-day end = next-day midnight
76+
returndayEnd<now;
77+
}
78+
5179
typeSyncState={
5280
calendarHref: string;
5381
initialSync: boolean;
@@ -1194,6 +1222,14 @@ export class AppleCalendar extends Connector<AppleCalendar> {
11941222

11951223
// Handle cancelled events
11961224
if(isCancelled){
1225+
// Drop the cancellation when the event has already ended — a past event's
1226+
// cancellation is just noise (and would flip the thread unread for a
1227+
// meeting that already happened). Incremental only: initial-sync
1228+
// cancellations already returned above.
1229+
if(cancellationIsForPastEventFn(start,end)){
1230+
returnnull;
1231+
}
1232+
11971233
constcancelNote={
11981234
key: "cancellation"asconst,
11991235
content: icsEvent.organizer?.name
@@ -1439,6 +1475,12 @@ export class AppleCalendar extends Connector<AppleCalendar> {
14391475
returnnull;
14401476
}
14411477

1478+
// Drop the cancellation when the occurrence has already ended — bumping
1479+
// the master thread for a past occurrence's cancellation is just noise.
1480+
if(cancellationIsForPastEventFn(start,end)){
1481+
returnnull;
1482+
}
1483+
14421484
return{
14431485
type: "event",
14441486
title: undefined,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import{describe,expect,it}from"vitest";
2+
import{cancellationIsForPastEventFn}from"./apple-calendar";
3+
4+
describe("cancellationIsForPastEventFn (apple-calendar)",()=>{
5+
constnow=newDate("2026-06-29T12:00:00.000Z");
6+
7+
it("treats a timed event that has already ended as past",()=>{
8+
conststart=newDate("2026-06-27T10:00:00.000Z");
9+
constend=newDate("2026-06-27T11:00:00.000Z");
10+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(true);
11+
});
12+
13+
it("keeps a timed event that has started but not finished",()=>{
14+
conststart=newDate("2026-06-29T11:00:00.000Z");
15+
constend=newDate("2026-06-29T13:00:00.000Z");// still running at noon
16+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
17+
});
18+
19+
it("keeps a future timed event",()=>{
20+
conststart=newDate("2026-07-05T10:00:00.000Z");
21+
constend=newDate("2026-07-05T11:00:00.000Z");
22+
expect(cancellationIsForPastEventFn(start,end,now)).toBe(false);
23+
});
24+
25+
it("treats a timed event with no end whose start is past as past",()=>{
26+
conststart=newDate("2026-06-27T10:00:00.000Z");
27+
expect(cancellationIsForPastEventFn(start,null,now)).toBe(true);
28+
});
29+
30+
it("treats an all-day event from yesterday as past (DTEND exclusive)",()=>{
31+
// All-day on 2026-06-28: DTSTART 2026-06-28, DTEND 2026-06-29 (exclusive).
32+
expect(cancellationIsForPastEventFn("2026-06-28","2026-06-29",now)).toBe(
33+
true
34+
);
35+
});
36+
37+
it("keeps an all-day event happening today (no end, runs to end of day)",()=>{
38+
// DTSTART 2026-06-29 with no DTEND → runs until 2026-06-30 midnight.
39+
expect(cancellationIsForPastEventFn("2026-06-29",null,now)).toBe(false);
40+
});
41+
42+
it("keeps a multi-day all-day event still in progress",()=>{
43+
// 2026-06-28 .. 2026-07-01 (exclusive end) — ends in the future.
44+
expect(cancellationIsForPastEventFn("2026-06-28","2026-07-01",now)).toBe(
45+
false
46+
);
47+
});
48+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{defineConfig}from"vitest/config";
2+
3+
exportdefaultdefineConfig({
4+
resolve: {
5+
// Resolve workspace connector packages from their TypeScript source
6+
// using the @plotday/connector export condition (same as the build path).
7+
conditions: ["@plotday/connector","default"],
8+
},
9+
test: {},
10+
});

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

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -913,14 +913,21 @@ describe("processCalendarEventsFn — stale cancellations", () => {
913913
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
914914

915915
// An upcoming event we imported, then cancelled after we started syncing.
916+
// Use a future start/end so this stays a "keep" case regardless of when the
917+
// test runs (a past event would be dropped by the past-cancellation guard).
918+
constfutureStart=newDate(Date.now()+7*24*60*60*1000);
916919
constfreshCancelled={
917920
id: "evt-fresh",
918921
iCalUID: "abc@google.com",
919922
status: "cancelled"asconst,
920923
created: "2026-06-01T10:00:00.000Z",
921924
updated: "2026-06-26T12:00:00.000Z",
922-
start: {dateTime: "2026-07-01T15:00:00.000Z"},
923-
end: {dateTime: "2026-07-01T16:00:00.000Z"},
925+
start: {dateTime: futureStart.toISOString()},
926+
end: {
927+
dateTime: newDate(
928+
futureStart.getTime()+60*60*1000
929+
).toISOString(),
930+
},
924931
summary: "Team sync",
925932
};
926933

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

1002+
// Future occurrence so this stays a "keep" case regardless of run date.
9951003
constfreshOccurrence={
9961004
id: "evt-occ-fresh",
9971005
iCalUID: "master2@google.com",
9981006
recurringEventId: "masterid2",
999-
originalStartTime: {dateTime: "2026-07-01T15:00:00.000Z"},
1007+
originalStartTime: {
1008+
dateTime: newDate(Date.now()+7*24*60*60*1000).toISOString(),
1009+
},
10001010
status: "cancelled"asconst,
10011011
updated: "2026-06-26T12:00:00.000Z",
10021012
};
@@ -1012,3 +1022,141 @@ describe("prepareEventInstanceFn — stale occurrence cancellations", () => {
10121022
).toBe(true);
10131023
});
10141024
});
1025+
1026+
describe("processCalendarEventsFn — past cancellations",()=>{
1027+
afterEach(()=>{
1028+
vi.unstubAllGlobals();
1029+
});
1030+
1031+
constisoDaysFromNow=(n: number)=>
1032+
newDate(Date.now()+n*24*60*60*1000).toISOString();
1033+
constdateDaysFromNow=(n: number)=>
1034+
newDate(Date.now()+n*24*60*60*1000).toISOString().slice(0,10);
1035+
1036+
it("drops a cancelled standalone event that has already ended",async()=>{
1037+
constcalendarId="user@example.com";
1038+
consthost=makeFakeHost({ calendarId });
1039+
// Synced a month ago; the event was imported then cancelled — but it has
1040+
// already happened, so surfacing the cancellation only flips unread noise.
1041+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1042+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1043+
1044+
constpastCancelled={
1045+
id: "evt-past",
1046+
iCalUID: "past@google.com",
1047+
status: "cancelled"asconst,
1048+
created: isoDaysFromNow(-10),
1049+
updated: isoDaysFromNow(-1),// recent edit, so the unimported guard keeps it
1050+
start: {dateTime: isoDaysFromNow(-2)},
1051+
end: {dateTime: isoDaysFromNow(-2)},// ended ~2 days ago
1052+
summary: "Old standup",
1053+
};
1054+
1055+
awaitprocessCalendarEventsFn(host,[pastCancelled],calendarId,false);
1056+
1057+
expect(host.savedLinks.flat()).toHaveLength(0);
1058+
});
1059+
1060+
it("keeps a cancelled standalone event that has started but not finished",async()=>{
1061+
constcalendarId="user@example.com";
1062+
consthost=makeFakeHost({ calendarId });
1063+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1064+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1065+
1066+
constongoingCancelled={
1067+
id: "evt-ongoing",
1068+
iCalUID: "ongoing@google.com",
1069+
status: "cancelled"asconst,
1070+
created: isoDaysFromNow(-10),
1071+
updated: isoDaysFromNow(0),
1072+
start: {dateTime: isoDaysFromNow(-1)},// started yesterday
1073+
end: {dateTime: isoDaysFromNow(1)},// ends tomorrow — still running
1074+
summary: "Multi-day workshop",
1075+
};
1076+
1077+
awaitprocessCalendarEventsFn(host,[ongoingCancelled],calendarId,false);
1078+
1079+
constsaved=host.savedLinks.flat();
1080+
expect(saved).toHaveLength(1);
1081+
expect(
1082+
saved[0].notes?.some(
1083+
(n)=>(nas{key?: string}).key==="cancellation"
1084+
)
1085+
).toBe(true);
1086+
});
1087+
1088+
it("keeps a cancelled standalone event in the future",async()=>{
1089+
constcalendarId="user@example.com";
1090+
consthost=makeFakeHost({ calendarId });
1091+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-30));
1092+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1093+
1094+
constfutureCancelled={
1095+
id: "evt-future",
1096+
iCalUID: "future@google.com",
1097+
status: "cancelled"asconst,
1098+
created: isoDaysFromNow(-10),
1099+
updated: isoDaysFromNow(-1),
1100+
start: {dateTime: isoDaysFromNow(7)},
1101+
end: {dateTime: isoDaysFromNow(7)},
1102+
summary: "Upcoming review",
1103+
};
1104+
1105+
awaitprocessCalendarEventsFn(host,[futureCancelled],calendarId,false);
1106+
1107+
expect(host.savedLinks.flat()).toHaveLength(1);
1108+
});
1109+
1110+
it("drops a cancelled recurring occurrence whose occurrence is in the past",async()=>{
1111+
constcalendarId="user@example.com";
1112+
consthost=makeFakeHost({ calendarId });
1113+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1114+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1115+
1116+
// Mirrors the prod bug: a recurring occurrence cancelled ~weeks after it
1117+
// already happened. originalStartTime is within the 2-year history window
1118+
// and updated is recent (so the unimported guard keeps it), but the
1119+
// occurrence is fully in the past — surfacing it just flips unread noise.
1120+
constpastOccurrence={
1121+
id: "evt-occ-past",
1122+
iCalUID: "recurring@google.com",
1123+
recurringEventId: "recurringid",
1124+
originalStartTime: {dateTime: isoDaysFromNow(-30)},
1125+
status: "cancelled"asconst,
1126+
updated: isoDaysFromNow(-1),
1127+
};
1128+
1129+
awaitprocessCalendarEventsFn(host,[pastOccurrence],calendarId,false);
1130+
1131+
expect(host.savedLinks.flat()).toHaveLength(0);
1132+
});
1133+
1134+
it("keeps a cancelled all-day recurring occurrence happening today",async()=>{
1135+
constcalendarId="user@example.com";
1136+
consthost=makeFakeHost({ calendarId });
1137+
host.store.set(`first_sync_at_${calendarId}`,isoDaysFromNow(-60));
1138+
vi.stubGlobal("fetch",vi.fn(async()=>makeEventsResponse([])));
1139+
1140+
// An all-day occurrence carries only a date (no time). Today's all-day
1141+
// event has started but not finished — it runs until end of day — so its
1142+
// cancellation must still surface.
1143+
consttodayAllDay={
1144+
id: "evt-occ-allday-today",
1145+
iCalUID: "recurring-allday@google.com",
1146+
recurringEventId: "recurringid-allday",
1147+
originalStartTime: {date: dateDaysFromNow(0)},
1148+
status: "cancelled"asconst,
1149+
updated: isoDaysFromNow(0),
1150+
};
1151+
1152+
awaitprocessCalendarEventsFn(host,[todayAllDay],calendarId,false);
1153+
1154+
constsaved=host.savedLinks.flat();
1155+
expect(saved).toHaveLength(1);
1156+
expect(
1157+
saved[0].notes?.some((n)=>
1158+
(nas{key?: string}).key?.startsWith("cancellation-")
1159+
)
1160+
).toBe(true);
1161+
});
1162+
});

0 commit comments

Comments
 (0)