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:
Henry
2025-12-05 21:49:45 +00:00
committed by GitHub
parent 88f1aa0ec4
commit d208952d15
39 changed files with 5224 additions and 722 deletions

View File

@@ -1,10 +1,17 @@
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
import { HiOutlinePaperClip } from "react-icons/hi";
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
import {
HiBars3BottomLeft,
HiChatBubbleLeft,
HiOutlineClock,
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import CircularProgress from "~/components/CircularProgress";
import LabelIcon from "~/components/LabelIcon";
import { useLocalisation } from "~/hooks/useLocalisation";
import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
@@ -15,6 +22,7 @@ const Card = ({
description,
comments,
attachments,
dueDate,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
@@ -36,7 +44,11 @@ const Card = ({
description: string | null;
comments: { publicId: string }[];
attachments?: { publicId: string }[];
dueDate?: Date | null;
}) => {
const { dateLocale } = useLocalisation();
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
const isOverdue = dueDate ? isBefore(dueDate, startOfDay(new Date())) : false;
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
}, 0);
@@ -51,6 +63,7 @@ const Card = ({
const hasDescription =
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasDueDate = !!dueDate;
return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
@@ -60,6 +73,7 @@ const Card = ({
checklists.length > 0 ||
hasDescription ||
comments.length > 0 ||
hasDueDate ||
hasAttachments ? (
<div className="mt-2 flex flex-col justify-end">
<div className="space-x-0.5">
@@ -77,6 +91,23 @@ const Card = ({
<HiBars3BottomLeft className="h-4 w-4" />
</div>
)}
{hasDueDate && dueDate && (
<div
className={twMerge(
"flex items-center gap-1",
isOverdue
? "text-red-600 dark:text-red-400"
: "text-light-800 dark:text-dark-800",
)}
>
<HiOutlineClock className="h-4 w-4" />
<span className="text-[11px]">
{format(dueDate, showYear ? "do MMM yyyy" : "do MMM", {
locale: dateLocale,
})}
</span>
</div>
)}
{comments.length > 0 && (
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
<HiChatBubbleLeft className="h-4 w-4" />

View File

@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiMiniXMark,
HiOutlineClock,
HiOutlineSquare3Stack3D,
HiOutlineTag,
HiOutlineUserCircle,
@@ -60,7 +61,13 @@ const Filters = ({
try {
await router.push({
pathname: router.pathname,
query: { ...router.query, members: [], labels: [], lists: [] },
query: {
...router.query,
members: [],
labels: [],
lists: [],
dueDate: [],
},
});
} catch (error) {
console.error(error);
@@ -99,6 +106,39 @@ const Filters = ({
selected: !!router.query.lists?.includes(list.publicId),
}));
const dueDateItems = [
{
key: "overdue",
value: t`Overdue`,
selected: !!router.query.dueDate?.includes("overdue"),
},
{
key: "today",
value: t`Due today`,
selected: !!router.query.dueDate?.includes("today"),
},
{
key: "tomorrow",
value: t`Due tomorrow`,
selected: !!router.query.dueDate?.includes("tomorrow"),
},
{
key: "next-week",
value: t`Due next week`,
selected: !!router.query.dueDate?.includes("next-week"),
},
{
key: "next-month",
value: t`Due next month`,
selected: !!router.query.dueDate?.includes("next-month"),
},
{
key: "no-due-date",
value: t`No dates`,
selected: !!router.query.dueDate?.includes("no-due-date"),
},
];
const groups = [
...(formattedMembers.length
? [
@@ -126,6 +166,12 @@ const Filters = ({
},
]
: []),
{
key: "dueDate",
label: t`Due date`,
icon: <HiOutlineClock size={16} />,
items: dueDateItems,
},
];
const handleSelect = async (
@@ -156,6 +202,7 @@ const Filters = ({
...formatToArray(router.query.members),
...formatToArray(router.query.labels),
...formatToArray(router.query.lists),
...formatToArray(router.query.dueDate),
].length;
return (

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useEffect } from "react";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import {
HiOutlineBarsArrowDown,
@@ -15,6 +16,7 @@ import type { WorkspaceMember } from "~/components/Editor";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import DateSelector from "~/components/DateSelector";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
@@ -27,6 +29,7 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
dueDate?: Date | null;
};
interface QueryParams {
@@ -65,6 +68,7 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
dueDate: null,
},
resetOnClose: true,
});
@@ -80,6 +84,8 @@ export function NewCardForm({
const position = watch("position");
const title = watch("title");
const description = watch("description");
const dueDate = watch("dueDate");
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
// saving form state whenever form values change
useEffect(() => {
@@ -137,6 +143,7 @@ export function NewCardForm({
title: args.title,
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -193,6 +200,7 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled,
position,
dueDate: null,
};
reset(newFormState);
saveFormState(newFormState);
@@ -242,7 +250,7 @@ export function NewCardForm({
),
})) ?? [];
const onSubmit = (data: NewCardInput) => {
const onSubmit = (data: NewCardFormInput) => {
createCard.mutate({
title: data.title,
description: data.description,
@@ -250,6 +258,7 @@ export function NewCardForm({
labelPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
position: data.position,
dueDate: data.dueDate ?? null,
});
};
@@ -448,6 +457,44 @@ export function NewCardForm({
</div>
</CheckboxDropdown>
</div>
<div className="relative w-fit">
<button
type="button"
onClick={() => setIsDateSelectorOpen(!isDateSelectorOpen)}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
) : (
<>{t`Due date`}</>
)}
</button>
{isDateSelectorOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsDateSelectorOpen(false)}
/>
<div
className="absolute left-0 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={dueDate ?? undefined}
onDateSelect={(date) => {
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
/>
</div>
</>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();

View File

@@ -29,6 +29,7 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
import { formatToArray } from "~/utils/helpers";
import BoardDropdown from "./components/BoardDropdown";
import Card from "./components/Card";
@@ -87,17 +88,28 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
});
};
const semanticFilters = formatToArray(router.query.dueDate) as (
| "overdue"
| "today"
| "tomorrow"
| "next-week"
| "next-month"
| "no-due-date"
)[];
const queryParams: {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
dueDate: ReturnType<typeof convertDueDateFiltersToRanges>;
type: "regular" | "template";
} = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
lists: formatToArray(router.query.lists),
dueDate: convertDueDateFiltersToRanges(semanticFilters),
type: isTemplate ? "template" : "regular",
};
@@ -567,6 +579,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
}
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
/>
</Link>
)}