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:
136
apps/web/src/components/DateSelector.tsx
Normal file
136
apps/web/src/components/DateSelector.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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-light-1000 dark:text-dark-1000">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreviousMonth}
|
||||
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<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-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
|
||||
>
|
||||
<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-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
|
||||
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
<time
|
||||
dateTime={day.date}
|
||||
className={twMerge(
|
||||
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
|
||||
day.isCurrentMonth
|
||||
? "text-light-900 dark:text-dark-900"
|
||||
: "text-light-700 dark:text-dark-600",
|
||||
day.isSelected && "text-light-50 dark:text-dark-50",
|
||||
)}
|
||||
>
|
||||
{day.date.split("-").pop()?.replace(/^0/, "")}
|
||||
</time>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DateSelector;
|
||||
Reference in New Issue
Block a user