Skip to content

Commit d21456b

Browse files
:bug fix(someday-events): event categorization - weekly recurrence in month 1087 (#1095)
1 parent ee80f50 commit d21456b

File tree

10 files changed

+138
-80
lines changed

10 files changed

+138
-80
lines changed

packages/core/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
"main": "",
88
"devDependencies": {
99
"@faker-js/faker": "^9.6.0",
10+
"@types/lodash.uniqby": "^4.7.9",
1011
"@types/tinycolor2": "^1.4.6",
1112
"typescript": "^5.1.6"
1213
},
1314
"dependencies": {
1415
"bson": "^6.10.4",
16+
"lodash.uniqby": "^4.7.0",
1517
"tinycolor2": "^1.6.0",
1618
"winston": "^3.8.1",
1719
"zod": "^3.25.76"

packages/core/src/util/test/ccal.event.factory.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "@core/types/event.types";
1111
import dayjs from "@core/util/date/dayjs";
1212
import { isAllDay, parseCompassEventDate } from "@core/util/event/event.util";
13+
import { CompassEventRRule } from "../event/compass.event.rrule";
1314

1415
export const createMockStandaloneEvent = (
1516
overrides: Partial<Omit<Schema_Event, "endDate">> = {},
@@ -117,23 +118,12 @@ export const createMockInstances = (
117118
count: number,
118119
overrides: Partial<Schema_Event_Recur_Instance> = {},
119120
): WithCompassId<Schema_Event_Recur_Instance>[] => {
120-
const instances: WithCompassId<Schema_Event_Recur_Instance>[] = [];
121-
const baseDate = new Date(baseEvent.startDate || "2024-03-20T10:00:00Z");
121+
const _id = new ObjectId(baseEvent._id);
122+
const rrule = new CompassEventRRule({ ...baseEvent, _id }, { count });
122123

123-
for (let i = 0; i < count; i++) {
124-
const instanceDate = new Date(baseDate);
125-
instanceDate.setDate(instanceDate.getDate() + (i + 1) * 7); // Weekly recurrence
126-
127-
instances.push(
128-
createMockInstance(baseEvent._id || "", baseEvent.gEventId as string, {
129-
startDate: instanceDate.toISOString(),
130-
endDate: new Date(instanceDate.getTime() + 3600000).toISOString(), // 1 hour later
131-
...overrides,
132-
}),
133-
);
134-
}
135-
136-
return instances;
124+
return rrule
125+
.instances()
126+
.map((i) => ({ ...i, ...overrides, _id: i._id.toString() }));
137127
};
138128

139129
export const generateCompassEventDates = ({

packages/scripts/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@
1010
"commander": "^10.0.0",
1111
"dotenv": "^16.0.1",
1212
"inquirer": "^8.0.0",
13-
"lodash.uniqby": "^4.7.0",
1413
"shelljs": "^0.8.5",
1514
"umzug": "^3.8.2"
1615
},
1716
"devDependencies": {
1817
"@types/inquirer": "^9.0.1",
19-
"@types/lodash.uniqby": "^4.7.9",
2018
"@types/node": "^24.7.2",
2119
"@types/shelljs": "^0.8.15",
2220
"ts-node-dev": "^2.0.0",

packages/web/src/common/utils/event/someday.event.util.test.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { ObjectId } from "bson";
2+
import { faker } from "@faker-js/faker";
23
import {
34
ID_OPTIMISTIC_PREFIX,
45
Origin,
56
Priorities,
7+
RRULE,
8+
RRULE_COUNT_WEEKS,
69
} from "@core/constants/core.constants";
710
import { Schema_Event } from "@core/types/event.types";
811
import dayjs from "@core/util/date/dayjs";
12+
import {
13+
createMockBaseEvent,
14+
createMockInstances,
15+
} from "@core/util/test/ccal.event.factory";
916
import { COLUMN_MONTH, COLUMN_WEEK } from "@web/common/constants/web.constants";
10-
import { Schema_SomedayEvent } from "@web/common/types/web.event.types";
17+
import {
18+
Schema_SomedayEvent,
19+
Schema_SomedayEventsColumn,
20+
} from "@web/common/types/web.event.types";
1121
import {
1222
categorizeSomedayEvents,
1323
setSomedayEventsOrder,
@@ -28,10 +38,8 @@ describe("categorizeSomedayEvents", () => {
2838
endDate: "2024-03-20",
2939
};
3040

31-
const weekDates = {
32-
start: dayjs("2024-03-17"),
33-
end: dayjs("2024-03-23"),
34-
};
41+
const startOfWeek = dayjs("2024-03-17").startOf("week");
42+
const weekDates = { start: startOfWeek, end: startOfWeek.endOf("week") };
3543

3644
describe("Week vs Month categorization", () => {
3745
it("should categorize event within current week to week column", () => {
@@ -97,13 +105,13 @@ describe("categorizeSomedayEvents", () => {
97105
...baseEvent,
98106
_id: _idA,
99107
startDate: "2024-03-17",
100-
endDate: "2024-03-23",
108+
endDate: "2024-03-17",
101109
},
102110
[_idB]: {
103111
...baseEvent,
104112
_id: _idB,
105113
startDate: "2024-03-01",
106-
endDate: "2024-03-31",
114+
endDate: "2024-03-01",
107115
},
108116
};
109117

@@ -471,5 +479,39 @@ describe("computeRelativeEventDateRange", () => {
471479
{ ...events[2], order: 3 },
472480
]);
473481
});
482+
483+
// Recurrence deduplication tests
484+
it("should not duplicate a recurring event into the month column when a week occurrence exists", () => {
485+
const isSomeday = true;
486+
const referenceDate = dayjs(faker.date.anytime());
487+
const startDate = referenceDate.toRFC3339OffsetString();
488+
const week = faker.number.int({ min: 0, max: RRULE_COUNT_WEEKS - 1 });
489+
const startOfWeek = referenceDate.startOf("week").add(week, "weeks");
490+
const endOfWeek = startOfWeek.endOf("week");
491+
const weekDates = { start: startOfWeek, end: endOfWeek };
492+
const recurrence = { rule: [RRULE.WEEK] };
493+
const base = createMockBaseEvent({ recurrence, startDate, isSomeday });
494+
const instances = createMockInstances(base, RRULE_COUNT_WEEKS, {
495+
isSomeday,
496+
order: 0,
497+
});
498+
499+
const events: Schema_SomedayEventsColumn["events"] = instances.reduce(
500+
(acc, instance) => ({
501+
...acc,
502+
[instance._id]: {
503+
...instance,
504+
recurrence: { ...recurrence, ...instance.recurrence },
505+
},
506+
}),
507+
{},
508+
);
509+
510+
const result = categorizeSomedayEvents(events, weekDates);
511+
const weekOccurrenceId = instances[week]._id;
512+
513+
expect(result.columns[COLUMN_MONTH].eventIds).toHaveLength(0);
514+
expect(result.columns[COLUMN_WEEK].eventIds).toContain(weekOccurrenceId);
515+
});
474516
});
475517
});

packages/web/src/common/utils/event/someday.event.util.ts

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { RRULE } from "@core/constants/core.constants";
1+
import uniqby from "lodash.uniqby";
22
import { Categories_Event, Schema_Event } from "@core/types/event.types";
33
import dayjs, { Dayjs } from "@core/util/date/dayjs";
44
import {
@@ -32,63 +32,63 @@ export const getSomedayEventCategory = (
3232
return Categories_Event.SOMEDAY_WEEK;
3333
};
3434

35+
export function eventsBetweenDates(
36+
events: Schema_SomedayEvent[],
37+
start: Dayjs,
38+
end: Dayjs,
39+
): Schema_SomedayEvent[] {
40+
return events.filter((event) => {
41+
return dayjs(event.startDate).isBetween(start, end, null, "[]");
42+
});
43+
}
44+
3545
export const categorizeSomedayEvents = (
36-
somedayEvents: Schema_SomedayEventsColumn["events"],
46+
events: Schema_SomedayEventsColumn["events"],
3747
weekDates: { start: Dayjs; end: Dayjs },
3848
): Schema_SomedayEventsColumn => {
3949
const { start: weekStart, end: weekEnd } = weekDates;
40-
41-
let events = Object.values(somedayEvents) as Schema_SomedayEvent[];
42-
43-
events = validateSomedayEvents(events);
44-
45-
const sortedEvents = events.sort((a, b) => a.order - b.order);
46-
47-
const weekIds: string[] = [];
48-
const monthIds: string[] = [];
49-
50-
sortedEvents.forEach((e) => {
51-
const eventStart = dayjs(e.startDate);
52-
const eventEnd = dayjs(e.endDate);
53-
const isWeek =
54-
eventStart.isSameOrAfter(weekStart) && eventEnd.isSameOrBefore(weekEnd);
55-
56-
if (isWeek) {
57-
const isMonthRepeat = e?.recurrence?.rule?.includes(RRULE.MONTH);
58-
if (!isMonthRepeat) {
59-
weekIds.push(e._id!);
60-
return;
61-
}
62-
}
63-
64-
const isFutureWeekThisMonth = e?.recurrence?.rule?.includes(RRULE.WEEK);
65-
if (isFutureWeekThisMonth) {
66-
return;
67-
}
68-
69-
const monthStart = weekStart.startOf("month");
70-
const monthEnd = weekStart.endOf("month");
71-
const isMonth = eventStart.isBetween(monthStart, monthEnd, null, "[]");
72-
73-
if (isMonth) {
74-
monthIds.push(e._id!);
75-
}
76-
});
50+
const monthStart = weekStart.startOf("month");
51+
const monthEnd = weekStart.endOf("month");
52+
const _events = Object.values(events) as Schema_SomedayEvent[];
53+
const somedayEvents = validateSomedayEvents(_events);
54+
const _weekEvents = eventsBetweenDates(somedayEvents, weekStart, weekEnd);
55+
const _monthEvents = eventsBetweenDates(somedayEvents, monthStart, monthEnd);
56+
const weekEvents = uniqby(_weekEvents, (e) => e.recurrence?.eventId ?? e._id);
57+
58+
const otherMonthEvents = _monthEvents.filter(
59+
({ _id, recurrence }) =>
60+
!weekEvents.some(
61+
(e) =>
62+
e._id === _id ||
63+
(typeof e.recurrence?.eventId === "string" &&
64+
e.recurrence?.eventId === recurrence?.eventId),
65+
),
66+
);
67+
68+
const monthEvents = uniqby(
69+
otherMonthEvents,
70+
(e) => e.recurrence?.eventId ?? e._id,
71+
);
7772

7873
const sortedData = {
7974
columns: {
8075
[COLUMN_WEEK]: {
8176
id: `${COLUMN_WEEK}`,
82-
eventIds: weekIds,
77+
eventIds: weekEvents
78+
.sort((a, b) => a.order - b.order)
79+
.map((e) => e._id!),
8380
},
8481
[COLUMN_MONTH]: {
8582
id: `${COLUMN_MONTH}`,
86-
eventIds: monthIds,
83+
eventIds: monthEvents
84+
.sort((a, b) => a.order - b.order)
85+
.map((e) => e._id!),
8786
},
8887
},
8988
columnOrder: [COLUMN_WEEK, COLUMN_MONTH],
90-
events: somedayEvents,
89+
events,
9190
};
91+
9292
return sortedData;
9393
};
9494

packages/web/src/ducks/events/sagas/someday.sagas.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
_assembleGridEvent,
1616
_createOptimisticGridEvent,
1717
_editEvent,
18+
getEventById,
1819
normalizedEventsSchema,
1920
replaceOptimisticId,
2021
} from "@web/ducks/events/sagas/saga.util";
@@ -55,14 +56,29 @@ export function* convertSomedayToCalendarEvent({
5556
}
5657

5758
export function* deleteSomedayEvent({ payload }: Action_DeleteEvent) {
59+
const event = yield* getEventById(payload._id);
60+
61+
if (!event) {
62+
console.error(`Event with ID ${payload._id} not found for deletion.`);
63+
return;
64+
}
65+
5866
try {
5967
yield put(eventsEntitiesSlice.actions.delete(payload));
6068

6169
yield call(EventApi.delete, payload._id, payload.applyTo);
6270
} catch (error) {
63-
yield put(getSomedayEventsSlice.actions.error());
71+
yield put(
72+
getSomedayEventsSlice.actions.error({
73+
__context: { reason: (error as Error).message },
74+
}),
75+
);
6476
handleError(error as Error);
65-
yield put(getSomedayEventsSlice.actions.request());
77+
yield put(
78+
eventsEntitiesSlice.actions.insert({
79+
[payload._id]: event as Schema_Event,
80+
}),
81+
);
6682
}
6783
}
6884

@@ -92,17 +108,24 @@ export function* getSomedayEvents({ payload }: Action_GetEvents) {
92108
offset: res.offset,
93109
}),
94110
);
95-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
96111
} catch (error) {
97-
yield put(getSomedayEventsSlice.actions.error());
112+
yield put(
113+
getSomedayEventsSlice.actions.error({
114+
__context: { reason: (error as Error).message },
115+
}),
116+
);
98117
}
99118
}
100119

101120
export function* reorderSomedayEvents({ payload }: Action_Someday_Reorder) {
102121
try {
103122
yield call(EventApi.reorder, payload);
104123
} catch (error) {
105-
yield put(getSomedayEventsSlice.actions.error());
124+
yield put(
125+
getSomedayEventsSlice.actions.error({
126+
__context: { reason: (error as Error).message },
127+
}),
128+
);
106129
handleError(error as Error);
107130
}
108131
}

packages/web/src/ducks/events/slices/view.slice.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ interface Action_ReminderChange extends Action {
3030

3131
const initialState: State_View = {
3232
dates: {
33-
start: dayjs().format(),
33+
start: dayjs().startOf("week").format(),
3434
end: dayjs().endOf("week").format(),
3535
},
3636
sidebar: { tab: "tasks", isOpen: true },

packages/web/src/views/Calendar/components/Sidebar/SomedayTab/SomedayEvents/SomedayEventContainer/SomedayEventRectangle.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import React from "react";
21
import { Categories_Event, Schema_Event } from "@core/types/event.types";
32
import { Flex } from "@web/components/Flex";
4-
import { AlignItems, JustifyContent } from "@web/components/Flex/styled";
5-
import { FlexDirections } from "@web/components/Flex/styled";
3+
import {
4+
AlignItems,
5+
FlexDirections,
6+
JustifyContent,
7+
} from "@web/components/Flex/styled";
68
import { Text } from "@web/components/Text";
79
import { Props_DraftForm } from "@web/views/Calendar/components/Draft/context/DraftContext";
810
import { Actions_Sidebar } from "@web/views/Calendar/components/Draft/sidebar/hooks/useSidebarActions";

packages/web/src/views/Calendar/hooks/useRefresh.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,10 @@ export const useRefresh = () => {
4141
break;
4242
}
4343
case Sync_AsyncStateContextReason.SOCKET_SOMEDAY_EVENT_CHANGED: {
44+
const dateStart = dayjs(start);
4445
const { startDate, endDate } = computeSomedayEventsRequestFilter(
45-
dayjs(start),
46-
dayjs(end),
46+
dateStart,
47+
dateStart.endOf("month"),
4748
);
4849

4950
dispatch(

packages/web/src/views/Calendar/hooks/useWeek.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ export const useWeek = (today: Dayjs) => {
4242
}, [dispatch, end, start]);
4343

4444
const somedayEventsRequestFilter = useMemo(() => {
45-
return computeSomedayEventsRequestFilter(start, end);
46-
}, [end, start]);
45+
return computeSomedayEventsRequestFilter(start, start.endOf("month"));
46+
}, [start]);
4747

4848
useEffect(() => {
4949
dispatch(

0 commit comments

Comments
 (0)