feat: add date selector
This commit is contained in:
135
apps/web/src/components/DateSelector.tsx
Normal file
135
apps/web/src/components/DateSelector.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="w-[250px] p-4">
|
||||||
|
<div className="flex items-center text-gray-900 dark:text-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePreviousMonth}
|
||||||
|
className="flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Previous month</span>
|
||||||
|
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 text-center text-sm font-semibold">
|
||||||
|
{monthName} {year}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNextMonth}
|
||||||
|
className="flex flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 dark:text-gray-500 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<span className="sr-only">Next month</span>
|
||||||
|
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
|
||||||
|
{dayHeaders.map((day, index) => (
|
||||||
|
<div key={index}>{day}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="isolate mt-2 grid grid-cols-7 text-sm">
|
||||||
|
{days.map((day) => (
|
||||||
|
<button
|
||||||
|
key={day.date}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleDateClick(day.dateObj, e)}
|
||||||
|
className={twMerge(
|
||||||
|
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
|
||||||
|
day.isSelected
|
||||||
|
? "bg-dark-1000 text-dark-50 hover:bg-dark-1000 dark:bg-dark-1000 dark:text-dark-50 dark:hover:bg-dark-1000"
|
||||||
|
: "bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<time
|
||||||
|
dateTime={day.date}
|
||||||
|
className={twMerge(
|
||||||
|
"mx-auto flex size-7 items-center justify-center rounded-full",
|
||||||
|
day.isCurrentMonth
|
||||||
|
? "text-light-900 dark:text-dark-900"
|
||||||
|
: "text-light-700 dark:text-dark-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day.date.split("-").pop()?.replace(/^0/, "")}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateSelector;
|
||||||
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-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"
|
||||||
|
>
|
||||||
|
{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-4 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
|||||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||||
import Dropdown from "./components/Dropdown";
|
import Dropdown from "./components/Dropdown";
|
||||||
|
import { DueDateSelector } from "./components/DueDateSelector";
|
||||||
import LabelSelector from "./components/LabelSelector";
|
import LabelSelector from "./components/LabelSelector";
|
||||||
import ListSelector from "./components/ListSelector";
|
import ListSelector from "./components/ListSelector";
|
||||||
import MemberSelector from "./components/MemberSelector";
|
import MemberSelector from "./components/MemberSelector";
|
||||||
@@ -124,7 +125,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isTemplate && (
|
{!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>
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||||
<MemberSelector
|
<MemberSelector
|
||||||
cardPublicId={cardId ?? ""}
|
cardPublicId={cardId ?? ""}
|
||||||
@@ -133,6 +134,14 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user