diff --git a/docs/plans/2026-02-15-custom-date-picker-implementation.md b/docs/plans/2026-02-15-custom-date-picker-implementation.md new file mode 100644 index 0000000..b269d4f --- /dev/null +++ b/docs/plans/2026-02-15-custom-date-picker-implementation.md @@ -0,0 +1,456 @@ +# 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 ``. 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("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 ( + + {children} + + {/* Navigation header */} +
+ + +
+ + +
+ + +
+ + {/* Body: days / months / years */} +
+ + {viewMode === "days" && ( + + {/* Weekday headers */} +
+ {WEEKDAYS.map((wd) => ( +
+ {wd} +
+ ))} +
+ + {/* Day grid */} +
+ {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
; + } + + return ( + + ); + })} +
+ + )} + + {viewMode === "months" && ( + + {MONTH_NAMES.map((name, i) => ( + + ))} + + )} + + {viewMode === "years" && ( + + {yearRange.map((year) => ( + + ))} + + )} + +
+ + {/* Footer */} +
+ + +
+ + + ); +} +``` + +**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 ( +
+ {/* Header with clear button */} +
+

+ Due Date +

+ {dueDate && ( + + )} +
+ + {/* Clickable date display → opens calendar */} + + + +
+ ); +} +``` + +**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" +``` diff --git a/src/components/card-detail/CalendarPopover.tsx b/src/components/card-detail/CalendarPopover.tsx new file mode 100644 index 0000000..5e2bd81 --- /dev/null +++ b/src/components/card-detail/CalendarPopover.tsx @@ -0,0 +1,280 @@ +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"; + +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("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 6x7 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 ( + + {children} + + {/* Navigation header */} +
+ + +
+ + +
+ + +
+ + {/* Body: days / months / years */} +
+ + {viewMode === "days" && ( + + {/* Weekday headers */} +
+ {WEEKDAYS.map((wd) => ( +
+ {wd} +
+ ))} +
+ + {/* Day grid */} +
+ {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
; + } + + return ( + + ); + })} +
+ + )} + + {viewMode === "months" && ( + + {MONTH_NAMES.map((name, i) => ( + + ))} + + )} + + {viewMode === "years" && ( + + {yearRange.map((year) => ( + + ))} + + )} + +
+ + {/* Footer */} +
+ + +
+ + + ); +} diff --git a/src/components/card-detail/DueDatePicker.tsx b/src/components/card-detail/DueDatePicker.tsx index 02ec36d..53793e8 100644 --- a/src/components/card-detail/DueDatePicker.tsx +++ b/src/components/card-detail/DueDatePicker.tsx @@ -1,6 +1,7 @@ import { format, formatDistanceToNow, isPast, isToday } from "date-fns"; -import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; import { useBoardStore } from "@/stores/board-store"; +import { CalendarPopover } from "@/components/card-detail/CalendarPopover"; interface DueDatePickerProps { cardId: string; @@ -13,70 +14,67 @@ export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) { const dateObj = dueDate ? new Date(dueDate) : null; const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj); - function handleChange(e: React.ChangeEvent) { - const val = e.target.value; - updateCard(cardId, { dueDate: val || null }); + function handleSelect(date: Date) { + updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") }); } function handleClear() { updateCard(cardId, { dueDate: null }); } - // Format the date value for the HTML date input (YYYY-MM-DD) - const inputValue = dateObj - ? format(dateObj, "yyyy-MM-dd") - : ""; - return (
- {/* Header */} -

- Due Date -

- - {/* Current date display */} - {dateObj && ( -
- - {format(dateObj, "MMM d, yyyy")} - - - {overdue - ? `overdue by ${formatDistanceToNow(dateObj)}` - : isToday(dateObj) - ? "today" - : `in ${formatDistanceToNow(dateObj)}`} - -
- )} - - {/* Date input + clear */} -
- + {/* Header with clear button */} +
+

+ Due Date +

{dueDate && ( - + + )}
+ + {/* Clickable date display -> opens calendar */} + + +
); }