From 7bc83e70a944260a47e5259cd19770344d814a26 Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 2 Dec 2025 22:36:32 +0000 Subject: [PATCH] feat: add date selector --- apps/web/src/components/DateSelector.tsx | 135 +++++++++++++++++ .../views/card/components/DueDateSelector.tsx | 141 ++++++++++++++++++ apps/web/src/views/card/index.tsx | 11 +- 3 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/DateSelector.tsx create mode 100644 apps/web/src/views/card/components/DueDateSelector.tsx diff --git a/apps/web/src/components/DateSelector.tsx b/apps/web/src/components/DateSelector.tsx new file mode 100644 index 00000000..a6bb3459 --- /dev/null +++ b/apps/web/src/components/DateSelector.tsx @@ -0,0 +1,135 @@ +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameDay, + isToday, + startOfMonth, + startOfWeek, + subMonths, +} from "date-fns"; +import { useMemo, useState } from "react"; +import { HiChevronLeft, HiChevronRight } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; + +interface DateSelectorProps { + selectedDate?: Date | null; + onDateSelect?: (date: Date | undefined) => void; +} + +const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => { + const [currentMonth, setCurrentMonth] = useState(() => { + return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date()); + }); + + const monthName = format(currentMonth, "MMMM"); + const year = format(currentMonth, "yyyy"); + + const dayHeaders = useMemo(() => { + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday + return eachDayOfInterval({ + start: weekStart, + end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000), + }).map((date) => format(date, "EEEEEE")); // Shortest localized day name + }, []); + + const days = useMemo(() => { + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday + + return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map( + (date) => { + const dateString = format(date, "yyyy-MM-dd"); + return { + date: dateString, + isToday: isToday(date), + isSelected: selectedDate ? isSameDay(date, selectedDate) : false, + isCurrentMonth: date >= monthStart && date <= monthEnd, + dateObj: date, + }; + }, + ); + }, [currentMonth, selectedDate]); + + const handlePreviousMonth = () => { + setCurrentMonth(subMonths(currentMonth, 1)); + }; + + const handleNextMonth = () => { + setCurrentMonth(addMonths(currentMonth, 1)); + }; + + const handleDateClick = (date: Date, e: React.MouseEvent) => { + e.stopPropagation(); + // If clicking the same date that's already selected, unselect it + if (selectedDate && isSameDay(date, selectedDate)) { + onDateSelect?.(undefined); + } else { + onDateSelect?.(date); + } + }; + + return ( +
+
+ +
+ {monthName} {year} +
+ +
+
+ {dayHeaders.map((day, index) => ( +
{day}
+ ))} +
+
+ {days.map((day) => ( + + ))} +
+
+ ); +}; + +export default DateSelector; diff --git a/apps/web/src/views/card/components/DueDateSelector.tsx b/apps/web/src/views/card/components/DueDateSelector.tsx new file mode 100644 index 00000000..22133767 --- /dev/null +++ b/apps/web/src/views/card/components/DueDateSelector.tsx @@ -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( + 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 ( +
+ + {isOpen && ( + <> +
+
{ + e.stopPropagation(); + }} + onMouseDown={(e) => { + e.stopPropagation(); + }} + > + +
+ + )} +
+ ); +} diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index 10e9de09..7dba67b1 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -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 }) { />
{!isTemplate && ( -
+

{t`Members`}

)} +
+

{t`Due date`}

+ +
); }