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

@@ -2,6 +2,7 @@ import type { Locale as DateFnsLocale } from "date-fns";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { format, formatDistanceToNow, isSameYear } from "date-fns";
import { useEffect, useRef, useState } from "react";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
@@ -15,11 +16,15 @@ import {
HiOutlineUserPlus,
} from "react-icons/hi2";
import type { GetCardByIdOutput } from "@kan/api/types";
import type {
GetCardActivitiesOutput,
GetCardByIdOutput,
} from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import { api } from "~/utils/api";
import Comment from "./Comment";
type ActivityType =
@@ -41,6 +46,7 @@ const getActivityText = ({
fromTitle,
toDueDate,
dateLocale,
mergedLabels,
}: {
type: ActivityType;
toTitle: string | null;
@@ -53,7 +59,42 @@ const getActivityText = ({
fromDueDate?: Date | null;
toDueDate?: Date | null;
dateLocale: DateFnsLocale;
mergedLabels?: string[];
}) => {
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (
type === "card.updated.label.added" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
added {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
if (
type === "card.updated.label.removed" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
removed {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`,
"card.updated.title": t`updated the title`,
@@ -79,12 +120,6 @@ const getActivityText = ({
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (type === "card.updated.title" && toTitle) {
return (
<Trans>
@@ -281,37 +316,151 @@ const getActivityIcon = (
return ACTIVITY_ICON_MAP[type] ?? null;
};
const ACTIVITIES_PAGE_SIZE = 20;
const ActivityList = ({
activities,
cardPublicId,
isLoading,
isLoading: cardIsLoading,
isAdmin,
isViewOnly,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
isAdmin?: boolean;
isViewOnly?: boolean;
}) => {
const { data } = authClient.useSession();
const { dateLocale } = useLocalisation();
const { dateLocale, locale } = useLocalisation();
const { data: sessionData } = authClient.useSession();
const utils = api.useUtils();
const [allActivities, setAllActivities] = useState<
GetCardActivitiesOutput["activities"]
>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const isFullyExpandedRef = useRef(false);
const lastDataUpdatedAtRef = useRef<number | null>(null);
const {
data: firstPageData,
isFetching: isFetchingFirst,
dataUpdatedAt,
} = api.card.getActivities.useQuery(
{
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
},
{
enabled: !!cardPublicId,
},
);
useEffect(() => {
if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) {
lastDataUpdatedAtRef.current = dataUpdatedAt;
if (isFullyExpandedRef.current && firstPageData.hasMore) {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
const fetchAllRemaining = async () => {
let currentActivities = [...firstPageData.activities];
let currentHasMore = firstPageData.hasMore;
while (currentHasMore) {
const lastActivity =
currentActivities[currentActivities.length - 1];
if (!lastActivity) break;
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(
currentActivities.map((a) => a.publicId),
);
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
} else {
break;
}
}
setAllActivities(currentActivities);
setHasMore(false);
};
fetchAllRemaining();
} else {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
if (!firstPageData.hasMore) {
isFullyExpandedRef.current = true;
}
}
}
}, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore || allActivities.length === 0) return;
const lastActivity = allActivities[allActivities.length - 1];
if (!lastActivity) return;
setIsLoadingMore(true);
try {
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(allActivities.map((a) => a.publicId));
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) {
isFullyExpandedRef.current = true;
}
}
} finally {
setIsLoadingMore(false);
}
};
const isFetching = isFetchingFirst || isLoadingMore;
const isLoading =
cardIsLoading || (isFetchingFirst && allActivities.length === 0);
return (
<div className="flex flex-col space-y-4 pt-4">
{activities.map((activity, index) => {
{allActivities.map((activity, index) => {
const activityText = getActivityText({
type: activity.type,
toTitle: activity.toTitle,
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === data?.user.id,
isSelf: activity.member?.user?.id === sessionData?.user.id,
label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale,
mergedLabels: (activity as any).mergedLabels,
});
if (activity.type === "card.updated.comment.added")
@@ -326,7 +475,7 @@ const ActivityList = ({
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === data?.user.id}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
@@ -351,11 +500,9 @@ const ActivityList = ({
)}
isLoading={isLoading}
/>
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
{index !== allActivities.length - 1 && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
@@ -373,6 +520,17 @@ const ActivityList = ({
</div>
);
})}
{hasMore && (
<div className="flex justify-center pt-4">
<button
onClick={handleLoadMore}
disabled={isFetching}
className="text-sm font-medium text-light-900 hover:text-light-1000 disabled:opacity-50 dark:text-dark-800 dark:hover:text-dark-1000"
>
{isFetching ? t`Loading...` : t`Load more activities`}
</button>
</div>
)}
</div>
);
};

View File

@@ -13,6 +13,7 @@ import {
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface Attachment {
publicId: string;
@@ -81,7 +82,7 @@ export function AttachmentThumbnails({
},
onSettled: async () => {
if (isReadOnly) return;
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -8,6 +8,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const { openModal } = useModal();
@@ -20,7 +21,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
const confirmAttachment = api.attachment.confirm.useMutation({
onSuccess: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
showPopup({
header: t`Attachment uploaded`,
message: t`Your file has been uploaded successfully.`,

View File

@@ -8,6 +8,7 @@ import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ChecklistItemRowProps {
item: {
@@ -68,7 +69,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});
@@ -96,7 +97,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export default function ChecklistNameInput({
checklistPublicId,
@@ -48,7 +49,7 @@ export default function ChecklistNameInput({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -11,6 +11,7 @@ import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -55,7 +56,7 @@ const Comment = ({
const updateCommentMutation = api.card.updateComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
await invalidateCard(utils, cardPublicId);
setIsEditing(false);
},
onError: () => {

View File

@@ -4,6 +4,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function DeleteChecklistConfirmation({
cardPublicId,
@@ -40,7 +41,7 @@ export function DeleteChecklistConfirmation({
},
onSettled: async () => {
closeModal();
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -4,6 +4,7 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DeleteCommentConfirmationProps {
cardPublicId: string;
@@ -47,7 +48,7 @@ export function DeleteCommentConfirmation({
});
},
onSettled: async () => {
await utils.card.byId.invalidate(queryParams);
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -6,6 +6,7 @@ import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DueDateSelectorProps {
cardPublicId: string;
@@ -61,7 +62,7 @@ export function DueDateSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});

View File

@@ -6,6 +6,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface LabelSelectorProps {
cardPublicId: string;
@@ -74,7 +75,7 @@ export default function LabelSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ListSelectorProps {
cardPublicId: string;
@@ -54,7 +55,7 @@ export default function ListSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -7,6 +7,7 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface MemberSelectorProps {
cardPublicId: string;
@@ -81,7 +82,7 @@ export default function MemberSelector({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -10,6 +10,7 @@ import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface NewChecklistFormInput {
name: string;
@@ -72,7 +73,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
});
},
onSettled: async (_data, _error, vars) => {
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
await invalidateCard(utils, vars.cardPublicId);
},
});

View File

@@ -7,6 +7,7 @@ import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
title: string;
@@ -91,7 +92,7 @@ const NewChecklistItemForm = ({
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -6,6 +6,7 @@ import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -34,10 +35,7 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
},
onSettled: async () => {
reset();
await utils.card.byId.invalidate(queryParams);
},
onSuccess: async () => {
await utils.card.byId.refetch();
await invalidateCard(utils, cardPublicId);
},
});

View File

@@ -17,6 +17,7 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList";
@@ -178,7 +179,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const board = card?.list.board;
const boardId = board?.publicId;
const activities = card?.activities;
const updateCard = api.card.update.useMutation({
onError: () => {
@@ -189,7 +189,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId: cardId });
if (cardId) await invalidateCard(utils, cardId);
},
});
@@ -388,7 +388,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>

View File

@@ -161,7 +161,6 @@ export function CardModal({
{cardPublicId && (
<ActivityList
cardPublicId={cardPublicId}
activities={data?.activities ?? []}
isLoading={isLoading}
isViewOnly={true}
/>