Files
kan/packages/api/src/utils/activities.ts
Ridham Khandar 57186b6b4d feat: paginate and merge frequent activities (#274)
* feat: paginate and merge frequent activities

* feat: due date activities

* fix: requested changes fixes

* refactor: slight reshuffle and improve types

* chore: translations

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
2025-12-16 09:07:08 +00:00

110 lines
2.8 KiB
TypeScript

import type { PaginatedActivitiesResult } from "@kan/db/repository/cardActivity.repo";
import type { ActivityType } from "@kan/db/schema";
type ActivityBase = PaginatedActivitiesResult["activities"][number];
interface Activity extends ActivityBase {
mergeCount?: number; // number of activities merged into this one
mergedLabels?: string[]; // list of label names when merging label activities
}
// types that can be merged with simple count
const MERGEABLE_COUNT_TYPES: readonly ActivityType[] = [
"card.updated.description",
];
// types that merge with a list of items
const MERGEABLE_LIST_TYPES: readonly ActivityType[] = [
"card.updated.label.added",
"card.updated.label.removed",
];
const MERGE_TIME_WINDOW_MS = 5 * 60 * 1000; // 5 minutes window for merging activities
export function mergeActivities(activities: Activity[]): Activity[] {
if (activities.length === 0) return [];
const merged: Activity[] = [];
let i = 0;
while (i < activities.length) {
const current = activities[i];
if (!current) {
i++;
continue;
}
const currentType = current.type;
const isCountMergeable = MERGEABLE_COUNT_TYPES.includes(currentType);
const isListMergeable = MERGEABLE_LIST_TYPES.includes(currentType);
const canMerge = (isCountMergeable || isListMergeable) && current.user?.id;
if (!canMerge) {
merged.push(current);
i++;
continue;
}
const group: Activity[] = [current];
let j = i + 1;
while (j < activities.length) {
const next = activities[j];
if (!next) break;
const timeDiff =
new Date(next.createdAt).getTime() -
new Date(current.createdAt).getTime();
if (
next.type === current.type &&
next.user?.id === current.user?.id &&
timeDiff >= 0 && // next is newer or same time
timeDiff <= MERGE_TIME_WINDOW_MS
) {
group.push(next);
j++;
} else {
break;
}
}
if (group.length > 1) {
const oldest = group[0];
const latest = group[group.length - 1];
if (oldest && latest) {
if (isListMergeable) {
const labelNames = group
.map((a) => a.label?.name)
.filter((name): name is string => !!name);
merged.push({
...oldest,
createdAt: oldest.createdAt,
mergeCount: group.length,
mergedLabels: labelNames,
});
} else {
merged.push({
...oldest,
createdAt: oldest.createdAt,
toDescription: latest.toDescription,
fromDescription: oldest.fromDescription,
mergeCount: group.length,
});
}
} else {
merged.push(current);
}
} else {
merged.push(current);
}
i = j;
}
return merged;
}