Files
openpylon/docs/plans/2026-02-15-custom-date-picker-implementation.md
Your Name bc12b5569a 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

14 KiB
Raw Permalink Blame History

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:

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

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:

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

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

git add -A
git commit -m "feat: custom date picker with calendar popover complete"