Files
openpylon/docs/plans/2026-02-15-custom-date-picker-implementation.md
Your Name 9db76881bd feat: custom calendar date picker replacing native input
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>.
2026-02-15 22:04:31 +02:00

457 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```