Build fully custom CalendarPopover with date-fns + Radix Popover. Month/year dropdown selectors, today button, clear button, past dates dimmed. Rewrite DueDatePicker to use it instead of <input type=date>.
457 lines
14 KiB
Markdown
457 lines
14 KiB
Markdown
# Custom Date Picker — Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Replace the native HTML date input with a fully custom calendar widget that matches the app's dark OKLCH theme.
|
||
|
||
**Architecture:** Create a new `CalendarPopover` component (calendar grid + month/year selectors + footer) using date-fns for date math and Radix Popover for positioning. Rewrite `DueDatePicker` to use it instead of `<input type="date">`. No new dependencies.
|
||
|
||
**Tech Stack:** React 19, TypeScript, date-fns v4, Framer Motion 12, Tailwind 4, Radix Popover
|
||
|
||
---
|
||
|
||
### Task 1: Create CalendarPopover component
|
||
|
||
**Files:**
|
||
- Create: `src/components/card-detail/CalendarPopover.tsx`
|
||
|
||
**Step 1: Create the file with the complete component**
|
||
|
||
Create `src/components/card-detail/CalendarPopover.tsx` with:
|
||
|
||
```tsx
|
||
import { useState, useMemo } from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||
import {
|
||
startOfMonth,
|
||
endOfMonth,
|
||
startOfWeek,
|
||
endOfWeek,
|
||
eachDayOfInterval,
|
||
format,
|
||
isSameDay,
|
||
isSameMonth,
|
||
isToday as isTodayFn,
|
||
isPast,
|
||
addMonths,
|
||
subMonths,
|
||
setMonth,
|
||
setYear,
|
||
getYear,
|
||
getMonth,
|
||
} from "date-fns";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Button } from "@/components/ui/button";
|
||
import { springs } from "@/lib/motion";
|
||
|
||
interface CalendarPopoverProps {
|
||
selectedDate: Date | null;
|
||
onSelect: (date: Date) => void;
|
||
onClear: () => void;
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
type ViewMode = "days" | "months" | "years";
|
||
|
||
const MONTH_NAMES = [
|
||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||
];
|
||
|
||
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||
|
||
export function CalendarPopover({
|
||
selectedDate,
|
||
onSelect,
|
||
onClear,
|
||
children,
|
||
}: CalendarPopoverProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [viewDate, setViewDate] = useState(() => selectedDate ?? new Date());
|
||
const [viewMode, setViewMode] = useState<ViewMode>("days");
|
||
|
||
// Reset view when opening
|
||
function handleOpenChange(nextOpen: boolean) {
|
||
if (nextOpen) {
|
||
setViewDate(selectedDate ?? new Date());
|
||
setViewMode("days");
|
||
}
|
||
setOpen(nextOpen);
|
||
}
|
||
|
||
function handleSelectDate(date: Date) {
|
||
onSelect(date);
|
||
setOpen(false);
|
||
}
|
||
|
||
function handleToday() {
|
||
const today = new Date();
|
||
onSelect(today);
|
||
setOpen(false);
|
||
}
|
||
|
||
function handleClear() {
|
||
onClear();
|
||
setOpen(false);
|
||
}
|
||
|
||
// Build the 6×7 grid of days for the current viewDate month
|
||
const calendarDays = useMemo(() => {
|
||
const monthStart = startOfMonth(viewDate);
|
||
const monthEnd = endOfMonth(viewDate);
|
||
const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
|
||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||
}, [viewDate]);
|
||
|
||
// Year range for year selector: current year ± 5
|
||
const yearRange = useMemo(() => {
|
||
const center = getYear(viewDate);
|
||
const years: number[] = [];
|
||
for (let y = center - 5; y <= center + 5; y++) {
|
||
years.push(y);
|
||
}
|
||
return years;
|
||
}, [viewDate]);
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||
<PopoverContent
|
||
align="start"
|
||
sideOffset={8}
|
||
className="w-[280px] bg-pylon-surface p-0 rounded-xl shadow-2xl border-border"
|
||
>
|
||
{/* Navigation header */}
|
||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-xs"
|
||
onClick={() => setViewDate((d) => subMonths(d, 1))}
|
||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||
>
|
||
<ChevronLeft className="size-4" />
|
||
</Button>
|
||
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setViewMode(viewMode === "months" ? "days" : "months")}
|
||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||
>
|
||
{format(viewDate, "MMMM")}
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode(viewMode === "years" ? "days" : "years")}
|
||
className="rounded-md px-2 py-0.5 text-sm font-medium text-pylon-text transition-colors hover:bg-pylon-column"
|
||
>
|
||
{format(viewDate, "yyyy")}
|
||
</button>
|
||
</div>
|
||
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-xs"
|
||
onClick={() => setViewDate((d) => addMonths(d, 1))}
|
||
className="text-pylon-text-secondary hover:text-pylon-text"
|
||
>
|
||
<ChevronRight className="size-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Body: days / months / years */}
|
||
<div className="p-3">
|
||
<AnimatePresence mode="wait">
|
||
{viewMode === "days" && (
|
||
<motion.div
|
||
key="days"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.15 }}
|
||
>
|
||
{/* Weekday headers */}
|
||
<div className="mb-1 grid grid-cols-7">
|
||
{WEEKDAYS.map((wd) => (
|
||
<div
|
||
key={wd}
|
||
className="flex h-8 items-center justify-center font-mono text-[10px] uppercase tracking-wider text-pylon-text-secondary"
|
||
>
|
||
{wd}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day grid */}
|
||
<div className="grid grid-cols-7">
|
||
{calendarDays.map((day) => {
|
||
const inMonth = isSameMonth(day, viewDate);
|
||
const today = isTodayFn(day);
|
||
const selected = selectedDate != null && isSameDay(day, selectedDate);
|
||
const past = isPast(day) && !today;
|
||
|
||
if (!inMonth) {
|
||
return <div key={day.toISOString()} className="h-9" />;
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={day.toISOString()}
|
||
onClick={() => handleSelectDate(day)}
|
||
className={`flex h-9 items-center justify-center rounded-lg text-sm transition-colors
|
||
${selected
|
||
? "bg-pylon-accent font-medium text-white"
|
||
: today
|
||
? "font-medium text-pylon-accent ring-1 ring-pylon-accent"
|
||
: past
|
||
? "text-pylon-text opacity-50 hover:bg-pylon-column hover:opacity-75"
|
||
: "text-pylon-text hover:bg-pylon-column"
|
||
}`}
|
||
>
|
||
{format(day, "d")}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{viewMode === "months" && (
|
||
<motion.div
|
||
key="months"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="grid grid-cols-3 gap-1"
|
||
>
|
||
{MONTH_NAMES.map((name, i) => (
|
||
<button
|
||
key={name}
|
||
onClick={() => {
|
||
setViewDate((d) => setMonth(d, i));
|
||
setViewMode("days");
|
||
}}
|
||
className={`rounded-lg py-2 text-sm transition-colors ${
|
||
getMonth(viewDate) === i
|
||
? "bg-pylon-accent font-medium text-white"
|
||
: "text-pylon-text hover:bg-pylon-column"
|
||
}`}
|
||
>
|
||
{name}
|
||
</button>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
|
||
{viewMode === "years" && (
|
||
<motion.div
|
||
key="years"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.15 }}
|
||
className="grid grid-cols-3 gap-1"
|
||
>
|
||
{yearRange.map((year) => (
|
||
<button
|
||
key={year}
|
||
onClick={() => {
|
||
setViewDate((d) => setYear(d, year));
|
||
setViewMode("days");
|
||
}}
|
||
className={`rounded-lg py-2 text-sm transition-colors ${
|
||
getYear(viewDate) === year
|
||
? "bg-pylon-accent font-medium text-white"
|
||
: "text-pylon-text hover:bg-pylon-column"
|
||
}`}
|
||
>
|
||
{year}
|
||
</button>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-between border-t border-border px-3 py-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={handleToday}
|
||
className="text-pylon-accent hover:text-pylon-accent"
|
||
>
|
||
Today
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="xs"
|
||
onClick={handleClear}
|
||
className="text-pylon-text-secondary hover:text-pylon-danger"
|
||
>
|
||
Clear
|
||
</Button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify TypeScript compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/card-detail/CalendarPopover.tsx
|
||
git commit -m "feat: create custom CalendarPopover component"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Rewrite DueDatePicker to use CalendarPopover
|
||
|
||
**Files:**
|
||
- Modify: `src/components/card-detail/DueDatePicker.tsx` (full rewrite)
|
||
|
||
**Step 1: Replace the entire file**
|
||
|
||
Replace `src/components/card-detail/DueDatePicker.tsx` with:
|
||
|
||
```tsx
|
||
import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
|
||
import { X } from "lucide-react";
|
||
import { useBoardStore } from "@/stores/board-store";
|
||
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
|
||
|
||
interface DueDatePickerProps {
|
||
cardId: string;
|
||
dueDate: string | null;
|
||
}
|
||
|
||
export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
|
||
const updateCard = useBoardStore((s) => s.updateCard);
|
||
|
||
const dateObj = dueDate ? new Date(dueDate) : null;
|
||
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
|
||
|
||
function handleSelect(date: Date) {
|
||
updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
|
||
}
|
||
|
||
function handleClear() {
|
||
updateCard(cardId, { dueDate: null });
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{/* Header with clear button */}
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||
Due Date
|
||
</h4>
|
||
{dueDate && (
|
||
<button
|
||
onClick={handleClear}
|
||
className="rounded p-0.5 text-pylon-text-secondary transition-colors hover:bg-pylon-danger/10 hover:text-pylon-danger"
|
||
aria-label="Clear due date"
|
||
>
|
||
<X className="size-3.5" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Clickable date display → opens calendar */}
|
||
<CalendarPopover
|
||
selectedDate={dateObj}
|
||
onSelect={handleSelect}
|
||
onClear={handleClear}
|
||
>
|
||
<button className="flex w-full items-center gap-2 rounded-md px-1 py-1 text-left transition-colors hover:bg-pylon-column/60">
|
||
{dateObj ? (
|
||
<>
|
||
<span
|
||
className={`text-sm font-medium ${
|
||
overdue ? "text-pylon-danger" : "text-pylon-text"
|
||
}`}
|
||
>
|
||
{format(dateObj, "MMM d, yyyy")}
|
||
</span>
|
||
<span
|
||
className={`text-xs ${
|
||
overdue ? "text-pylon-danger" : "text-pylon-text-secondary"
|
||
}`}
|
||
>
|
||
{overdue
|
||
? `overdue by ${formatDistanceToNow(dateObj)}`
|
||
: isToday(dateObj)
|
||
? "today"
|
||
: `in ${formatDistanceToNow(dateObj)}`}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="text-sm italic text-pylon-text-secondary/60">
|
||
Click to set date...
|
||
</span>
|
||
)}
|
||
</button>
|
||
</CalendarPopover>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Step 2: Verify TypeScript compiles**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/components/card-detail/DueDatePicker.tsx
|
||
git commit -m "feat: rewrite DueDatePicker to use custom CalendarPopover"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Visual verification and final commit
|
||
|
||
**Step 1: Run TypeScript check**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: No errors
|
||
|
||
**Step 2: Run the dev server**
|
||
|
||
Run: `npx tauri dev`
|
||
|
||
Verify:
|
||
- Open a card → Due Date cell shows "Click to set date..." or the current date
|
||
- Click the cell → calendar popover appears below
|
||
- Calendar shows correct month with today highlighted (ring)
|
||
- Click a date → it's selected (filled accent), popover closes, cell shows formatted date
|
||
- Click month name → month selector grid appears, click a month → returns to days
|
||
- Click year → year selector grid appears, click a year → returns to days
|
||
- Left/right arrows navigate months
|
||
- "Today" button selects today and closes
|
||
- "Clear" button in popover footer removes the date and closes
|
||
- × button in cell header clears the date without opening calendar
|
||
- Past dates are dimmed but clickable
|
||
- Overdue dates show in red
|
||
|
||
**Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: custom date picker with calendar popover complete"
|
||
```
|