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>.
This commit is contained in:
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal file
456
docs/plans/2026-02-15-custom-date-picker-implementation.md
Normal file
@@ -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 `<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"
|
||||||
|
```
|
||||||
280
src/components/card-detail/CalendarPopover.tsx
Normal file
280
src/components/card-detail/CalendarPopover.tsx
Normal file
@@ -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<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 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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { format, formatDistanceToNow, isPast, isToday } from "date-fns";
|
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 { useBoardStore } from "@/stores/board-store";
|
||||||
|
import { CalendarPopover } from "@/components/card-detail/CalendarPopover";
|
||||||
|
|
||||||
interface DueDatePickerProps {
|
interface DueDatePickerProps {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
@@ -13,30 +14,41 @@ export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
|
|||||||
const dateObj = dueDate ? new Date(dueDate) : null;
|
const dateObj = dueDate ? new Date(dueDate) : null;
|
||||||
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
|
const overdue = dateObj != null && isPast(dateObj) && !isToday(dateObj);
|
||||||
|
|
||||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleSelect(date: Date) {
|
||||||
const val = e.target.value;
|
updateCard(cardId, { dueDate: format(date, "yyyy-MM-dd") });
|
||||||
updateCard(cardId, { dueDate: val || null });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
updateCard(cardId, { dueDate: null });
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{/* Header */}
|
{/* Header with clear button */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
<h4 className="font-mono text-xs uppercase tracking-widest text-pylon-text-secondary">
|
||||||
Due Date
|
Due Date
|
||||||
</h4>
|
</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>
|
||||||
|
|
||||||
{/* Current date display */}
|
{/* Clickable date display -> opens calendar */}
|
||||||
{dateObj && (
|
<CalendarPopover
|
||||||
<div className="flex items-center gap-2">
|
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
|
<span
|
||||||
className={`text-sm font-medium ${
|
className={`text-sm font-medium ${
|
||||||
overdue ? "text-pylon-danger" : "text-pylon-text"
|
overdue ? "text-pylon-danger" : "text-pylon-text"
|
||||||
@@ -55,28 +67,14 @@ export function DueDatePicker({ cardId, dueDate }: DueDatePickerProps) {
|
|||||||
? "today"
|
? "today"
|
||||||
: `in ${formatDistanceToNow(dateObj)}`}
|
: `in ${formatDistanceToNow(dateObj)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm italic text-pylon-text-secondary/60">
|
||||||
|
Click to set date...
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
{/* Date input + clear */}
|
</CalendarPopover>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={inputValue}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="h-7 rounded-md border border-pylon-text-secondary/20 bg-pylon-column px-2 text-xs text-pylon-text outline-none focus:border-pylon-accent focus:ring-1 focus:ring-pylon-accent"
|
|
||||||
/>
|
|
||||||
{dueDate && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={handleClear}
|
|
||||||
className="text-pylon-text-secondary hover:text-pylon-danger"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user