feat: card due dates (#271)
* feat: add card due dates to schema * feat: update repo funcs * feat: update card router to support due dates * chore: update migration journal * feat: add date selector * feat: display date icon and label on cards * feat: add due date filters * feat: add due date to new card form * feat: light mode tweaks * feat: improve text eligibility on light mode * feat: reduce selector font size * feat: display date updates in card activity * chore: gen translations
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import { format, formatDistanceToNow, isSameYear } from "date-fns";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineClock,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
@@ -24,15 +25,6 @@ import Comment from "./Comment";
|
||||
type ActivityType =
|
||||
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
|
||||
|
||||
const dateLocaleMap = {
|
||||
en: enGB,
|
||||
fr: fr,
|
||||
de: de,
|
||||
es: es,
|
||||
it: it,
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
@@ -47,6 +39,8 @@ const getActivityText = ({
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
toDueDate,
|
||||
dateLocale,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -56,6 +50,9 @@ const getActivityText = ({
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
fromDueDate?: Date | null;
|
||||
toDueDate?: Date | null;
|
||||
dateLocale: DateFnsLocale;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
@@ -74,6 +71,9 @@ const getActivityText = ({
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
"card.updated.dueDate.added": t`set the due date`,
|
||||
"card.updated.dueDate.updated": t`updated the due date`,
|
||||
"card.updated.dueDate.removed": t`removed the due date`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
@@ -209,6 +209,38 @@ const getActivityText = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.added" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.updated" && toDueDate) {
|
||||
const showYear = !isSameYear(toDueDate, new Date());
|
||||
const formattedDate = format(
|
||||
toDueDate,
|
||||
showYear ? "do MMM yyyy" : "do MMM",
|
||||
{ locale: dateLocale },
|
||||
);
|
||||
return (
|
||||
<Trans>
|
||||
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.dueDate.removed") {
|
||||
return <Trans>removed the due date</Trans>;
|
||||
}
|
||||
|
||||
return baseText;
|
||||
};
|
||||
|
||||
@@ -229,6 +261,9 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
"card.updated.dueDate.added": <HiOutlineClock />,
|
||||
"card.updated.dueDate.updated": <HiOutlineClock />,
|
||||
"card.updated.dueDate.removed": <HiOutlineClock />,
|
||||
} as const;
|
||||
|
||||
const getActivityIcon = (
|
||||
@@ -260,9 +295,7 @@ const ActivityList = ({
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] || enGB;
|
||||
const { dateLocale } = useLocalisation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4 pt-4">
|
||||
@@ -276,6 +309,9 @@ const ActivityList = ({
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
fromDueDate: activity.fromDueDate ?? null,
|
||||
toDueDate: activity.toDueDate ?? null,
|
||||
dateLocale: dateLocale,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
@@ -330,7 +366,7 @@ const ActivityList = ({
|
||||
<span className="space-x-1 text-light-900 dark:text-dark-800">
|
||||
{formatDistanceToNow(new Date(activity.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: currentDateLocale,
|
||||
locale: dateLocale,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
141
apps/web/src/views/card/components/DueDateSelector.tsx
Normal file
141
apps/web/src/views/card/components/DueDateSelector.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { format } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
import DateSelector from "~/components/DateSelector";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface DueDateSelectorProps {
|
||||
cardPublicId: string;
|
||||
dueDate: Date | null | undefined;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DueDateSelector({
|
||||
cardPublicId,
|
||||
dueDate,
|
||||
isLoading = false,
|
||||
}: DueDateSelectorProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
|
||||
dueDate,
|
||||
);
|
||||
|
||||
// Sync pendingDate with dueDate when it changes externally
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPendingDate(dueDate);
|
||||
}
|
||||
}, [dueDate, isOpen]);
|
||||
|
||||
const updateDueDate = api.card.update.useMutation({
|
||||
onMutate: async (update) => {
|
||||
await utils.card.byId.cancel();
|
||||
|
||||
const previousCard = utils.card.byId.getData({ cardPublicId });
|
||||
|
||||
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
|
||||
if (!oldCard) return oldCard;
|
||||
|
||||
return {
|
||||
...oldCard,
|
||||
dueDate:
|
||||
update.dueDate !== undefined
|
||||
? (update.dueDate as Date | null)
|
||||
: oldCard.dueDate,
|
||||
};
|
||||
});
|
||||
|
||||
return { previousCard };
|
||||
},
|
||||
onError: (_error, _update, context) => {
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
|
||||
showPopup({
|
||||
header: t`Unable to update due date`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
// Only update local state, don't fire mutation
|
||||
setPendingDate(date ?? null);
|
||||
};
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
// Only fire mutation if date actually changed
|
||||
const pendingIsNull = pendingDate === null || pendingDate === undefined;
|
||||
const dueIsNull = dueDate === null || dueDate === undefined;
|
||||
|
||||
let dateChanged = false;
|
||||
if (pendingIsNull && !dueIsNull) {
|
||||
dateChanged = true;
|
||||
} else if (!pendingIsNull && dueIsNull) {
|
||||
dateChanged = true;
|
||||
} else if (!pendingIsNull && !dueIsNull) {
|
||||
// Both are non-null at this point
|
||||
if (pendingDate instanceof Date && dueDate instanceof Date) {
|
||||
dateChanged = pendingDate.getTime() !== dueDate.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
// Close popover immediately
|
||||
setIsOpen(false);
|
||||
|
||||
// Fire mutation if date changed (optimistic update will handle UI)
|
||||
if (dateChanged) {
|
||||
updateDueDate.mutate({
|
||||
cardPublicId,
|
||||
dueDate: pendingDate ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full items-center text-left">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
|
||||
>
|
||||
{dueDate ? (
|
||||
<span>{format(dueDate, "MMM d, yyyy")}</span>
|
||||
) : (
|
||||
<>
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Set due date`}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
|
||||
<div
|
||||
className="absolute -left-8 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DateSelector
|
||||
selectedDate={pendingDate ?? undefined}
|
||||
onDateSelect={handleDateSelect}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
|
||||
@@ -111,7 +111,7 @@ export default function MemberSelector({
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||
import Dropdown from "./components/Dropdown";
|
||||
import { DueDateSelector } from "./components/DueDateSelector";
|
||||
import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
@@ -124,7 +125,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
@@ -133,6 +134,14 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
|
||||
<DueDateSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
dueDate={card?.dueDate}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user