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>
This commit is contained in:
Ridham Khandar
2025-12-16 14:37:08 +05:30
committed by GitHub
parent 3cb40d0f9a
commit 57186b6b4d
40 changed files with 1515 additions and 884 deletions

View File

@@ -9,6 +9,7 @@ import * as listRepo from "@kan/db/repository/list.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { mergeActivities } from "../utils/activities";
import { assertUserInWorkspace } from "../utils/auth";
import { generateDownloadUrl } from "../utils/s3";
@@ -670,6 +671,81 @@ export const cardRouter = createTRPCRouter({
return { ...result, attachments: [] };
}),
getActivities: publicProcedure
.meta({
openapi: {
summary: "Get paginated card activities",
method: "GET",
path: "/cards/{cardPublicId}/activities",
description:
"Retrieves paginated activities for a card with merged frequent changes",
tags: ["Cards"],
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
limit: z.number().min(1).max(100).optional().default(10),
cursor: z.string().datetime().optional(), // ISO datetime string
}),
)
.output(
z.object({
activities: z.array(
z.custom<
NonNullable<
Awaited<
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
>
>["activities"][number]
>(),
),
hasMore: z.boolean(),
nextCursor: z.string().datetime().nullable(),
}),
)
.query(async ({ ctx, input }) => {
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
input.cardPublicId,
);
if (!card)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
if (card.workspaceVisibility === "private") {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
}
const cursor = input.cursor ? new Date(input.cursor) : undefined;
const result = await cardActivityRepo.getPaginatedActivities(
ctx.db,
card.id,
{
limit: input.limit,
cursor,
},
);
const mergedActivities = mergeActivities(result.activities);
return {
activities: mergedActivities,
hasMore: result.hasMore,
nextCursor: result.nextCursor?.toISOString() ?? null,
};
}),
update: protectedProcedure
.meta({
openapi: {

View File

@@ -2,6 +2,7 @@ import type { RouterInputs, RouterOutputs } from "../index";
export type GetBoardByIdOutput = RouterOutputs["board"]["byId"];
export type GetCardByIdOutput = RouterOutputs["card"]["byId"];
export type GetCardActivitiesOutput = RouterOutputs["card"]["getActivities"];
export type UpdateBoardInput = RouterInputs["board"]["update"];
export type NewLabelInput = RouterInputs["label"]["create"];
export type NewListInput = RouterInputs["list"]["create"];

View File

@@ -0,0 +1,109 @@
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;
}