Files
Jyotisha/docs/superpowers/plans/2026-07-17-birth-date-picker.md
T
2026-07-17 20:58:13 +08:00

15 KiB
Raw Blame History

Birth Date Picker Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the browser-native birth-date input with the user-supplied shadcn Popover + Calendar Date Picker while preserving the existing YYYY-MM-DD profile contract.

Architecture: Add the official shadcn Base Nova Calendar and Popover primitives, then compose them in a focused BirthDatePicker client component. Keep local-calendar parsing and serialization beside the existing birth-time draft model so every consumer receives the same date-only string without UTC shifts.

Tech Stack: Next.js 16, React 19, TypeScript, shadcn Base Nova, Base UI, React DayPicker, date-fns, Lucide, Node test runner.

Global Constraints

  • The closed control follows the exact shadcn composition supplied by the user: PopoverTrigger render={<Button />} plus PopoverContent containing a single-select Calendar.
  • Date values remain YYYY-MM-DD; the API payload and database schema do not change.
  • The selectable range is 1900-01-01 through the user's current local date; future dates are disabled.
  • The calendar uses Chinese locale with month and year dropdowns, newest years first.
  • Confirmed birth-time profiles keep the date trigger disabled.
  • Use only existing frontend/DESIGN.md tokens and shadcn semantic tokens; add no raw colors.
  • Preserve 44px targets, keyboard operation, focus visibility, and collision-safe popover positioning.

Task 1: Date-only value adapter

Files:

  • Modify: frontend/package.json
  • Modify: frontend/package-lock.json
  • Modify: frontend/src/lib/birth-time-intake-model.ts
  • Test: frontend/tests/birth-time-intake.test.ts

Interfaces:

  • Produces: parseBirthDate(value: string): Date | undefined

  • Produces: formatBirthDate(value: Date): string

  • Consumes: date-fns parse, format, and isValid

  • Step 1: Add failing date-only behavior tests

Extend the existing import and append these Given/When/Then tests:

import {
  assistantIntentCopy,
  birthTimePersistenceValues,
  describeBirthTimeDraft,
  formatBirthDate,
  isBirthTimeDraftReady,
  parseBirthDate,
  type BirthTimeDraft,
} from "../src/lib/birth-time-intake-model.ts";

test("birth date parsing preserves the local calendar day", () => {
  const parsed = parseBirthDate("1993-04-17");

  assert.equal(parsed?.getFullYear(), 1993);
  assert.equal(parsed?.getMonth(), 3);
  assert.equal(parsed?.getDate(), 17);
});

test("birth date values round trip leap days and reject invalid input", () => {
  const leapDay = parseBirthDate("2000-02-29");

  assert.equal(leapDay === undefined ? undefined : formatBirthDate(leapDay), "2000-02-29");
  assert.equal(parseBirthDate(""), undefined);
  assert.equal(parseBirthDate("2001-02-29"), undefined);
});
  • Step 2: Run the tests in a negative UTC offset and confirm red

Run:

TZ=America/Los_Angeles node --test tests/birth-time-intake.test.ts

Expected: FAIL because parseBirthDate and formatBirthDate are not exported yet.

  • Step 3: Install date-fns as a direct dependency

Run:

npm install date-fns

Expected: package.json and package-lock.json declare date-fns directly.

  • Step 4: Implement strict local-calendar parsing and formatting

Add to birth-time-intake-model.ts:

import { format, isValid, parse } from "date-fns";

const birthDatePattern = "yyyy-MM-dd";

export function parseBirthDate(value: string): Date | undefined {
  if (value === "") return undefined;
  const parsed = parse(value, birthDatePattern, new Date(2000, 0, 1));
  if (!isValid(parsed) || format(parsed, birthDatePattern) !== value) return undefined;
  return parsed;
}

export function formatBirthDate(value: Date): string {
  return format(value, birthDatePattern);
}
  • Step 5: Run the adapter tests and confirm green

Run:

TZ=America/Los_Angeles node --test tests/birth-time-intake.test.ts

Expected: all birth-time intake tests PASS.

  • Step 6: Commit the adapter
git add frontend/package.json frontend/package-lock.json frontend/src/lib/birth-time-intake-model.ts frontend/tests/birth-time-intake.test.ts
git commit -m "feat: add birth date value adapter"

Task 2: shadcn Calendar and Popover primitives

Files:

  • Create: frontend/src/components/ui/calendar.tsx
  • Create: frontend/src/components/ui/popover.tsx
  • Create: frontend/tests/birth-date-picker-contract.test.ts
  • Modify: frontend/package.json
  • Modify: frontend/package-lock.json

Interfaces:

  • Produces: Calendar(props: React.ComponentProps<typeof DayPicker>)

  • Produces: Popover, PopoverTrigger, and PopoverContent

  • Consumes: the existing Button, buttonVariants, cn, Lucide icons, and shadcn semantic CSS variables.

  • Step 1: Write the failing primitive contract test

Create tests/birth-date-picker-contract.test.ts:

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";

const packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8");

test("provides the shadcn calendar and popover primitives", () => {
  assert.equal(existsSync(new URL("../src/components/ui/calendar.tsx", import.meta.url)), true);
  assert.equal(existsSync(new URL("../src/components/ui/popover.tsx", import.meta.url)), true);
  assert.match(packageJson, /"react-day-picker"/);
});
  • Step 2: Run the primitive contract and confirm red

Run:

node --test tests/birth-date-picker-contract.test.ts

Expected: FAIL because the primitive files and direct dependency do not exist.

  • Step 3: Add the official Base Nova primitives

Run from frontend/:

npx shadcn@latest add calendar popover --yes

Expected: shadcn reads the existing components.json, creates src/components/ui/calendar.tsx and src/components/ui/popover.tsx, reuses the existing button.tsx, and adds react-day-picker plus required direct dependencies without changing the project style preset.

  • Step 4: Review generated changes before accepting them

Run:

git diff -- frontend/package.json frontend/package-lock.json frontend/src/components/ui/button.tsx frontend/src/components/ui/calendar.tsx frontend/src/components/ui/popover.tsx

Expected: no overwrite of the project's existing Button behavior; generated Calendar uses react-day-picker, and Popover uses the Base UI composition required by style: base-nova.

  • Step 5: Run the primitive contract and TypeScript

Run:

node --test tests/birth-date-picker-contract.test.ts
npx tsc --noEmit

Expected: both commands PASS.

  • Step 6: Commit the primitives
git add frontend/package.json frontend/package-lock.json frontend/src/components/ui/calendar.tsx frontend/src/components/ui/popover.tsx frontend/tests/birth-date-picker-contract.test.ts
git commit -m "feat: add shadcn calendar primitives"

Task 3: BirthDatePicker composition and intake integration

Files:

  • Create: frontend/src/components/birth-date-picker.tsx
  • Modify: frontend/src/components/birth-time-intake.tsx
  • Modify: frontend/DESIGN.md
  • Test: frontend/tests/birth-date-picker-contract.test.ts

Interfaces:

  • Consumes: parseBirthDate(value: string) and formatBirthDate(value: Date) from Task 1.

  • Consumes: Calendar, Popover, PopoverContent, PopoverTrigger, Button, CalendarIcon, and zhCN.

  • Produces: BirthDatePicker({ value, disabled, onChange }) where value is YYYY-MM-DD and onChange receives YYYY-MM-DD.

  • Step 1: Document the Date Picker primitive before product code

Extend frontend/DESIGN.md Section 5 with:

### Birth date picker

- **Composition:** shadcn outline Button trigger, Base UI Popover, and a single-select React DayPicker Calendar.
- **Range:** local dates from 1900-01-01 through today; future dates are disabled. Month and year dropdowns provide direct navigation, with newest years first.
- **Value:** display Chinese long dates while emitting the existing `YYYY-MM-DD` profile value without UTC conversion.
- **States:** empty, open, selected, focus-visible, disabled confirmed profile, and unavailable date.
- **Accessibility:** visible label, explicit trigger naming, 44px targets, keyboard calendar navigation, focus return, and collision-safe popup positioning.
  • Step 2: Add a failing integration contract

Append to birth-date-picker-contract.test.ts:

const intake = readFileSync(new URL("../src/components/birth-time-intake.tsx", import.meta.url), "utf8");
const pickerUrl = new URL("../src/components/birth-date-picker.tsx", import.meta.url);

test("replaces the native birth date input with the shadcn date picker", () => {
  assert.equal(existsSync(pickerUrl), true);
  assert.doesNotMatch(intake, /type="date"/);
  assert.match(intake, /<BirthDatePicker/);
  const picker = readFileSync(pickerUrl, "utf8");
  assert.match(picker, /<PopoverTrigger/);
  assert.match(picker, /render=\{<Button/);
  assert.match(picker, /<Calendar/);
  assert.match(picker, /captionLayout="dropdown"/);
  assert.match(picker, /startMonth=\{new Date\(1900, 0\)\}/);
  assert.match(picker, /reverseYears/);
});
  • Step 3: Run the integration contract and confirm red

Run:

node --test tests/birth-date-picker-contract.test.ts

Expected: FAIL because BirthDatePicker is absent and the native date input remains.

  • Step 4: Implement the focused picker component

Create src/components/birth-date-picker.tsx with this composition:

"use client";

import { format } from "date-fns";
import { zhCN } from "date-fns/locale";
import { CalendarIcon } from "lucide-react";
import { useId, useState } from "react";

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { formatBirthDate, parseBirthDate } from "@/lib/birth-time-intake-model";

type BirthDatePickerProps = {
  readonly value: string;
  readonly disabled: boolean;
  readonly onChange: (value: string) => void;
};

export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerProps) {
  const labelId = useId();
  const valueId = useId();
  const [open, setOpen] = useState(false);
  const selected = parseBirthDate(value);
  const today = new Date();
  today.setHours(0, 0, 0, 0);

  return (
    <div className="grid gap-2">
      <span id={labelId}>出生日期</span>
      <Popover open={open} onOpenChange={setOpen}>
        <PopoverTrigger
          render={
            <Button
              type="button"
              variant="outline"
              disabled={disabled}
              aria-labelledby={`${labelId} ${valueId}`}
              data-empty={selected === undefined}
              className="w-full justify-start px-3 text-left font-normal data-[empty=true]:text-muted-foreground"
            />
          }
        >
          <CalendarIcon aria-hidden="true" />
          <span id={valueId}>
            {selected === undefined
              ? "选择出生日期"
              : format(selected, "PPP", { locale: zhCN })}
          </span>
        </PopoverTrigger>
        <PopoverContent align="start" className="w-auto p-0">
          <Calendar
            key={value || "empty"}
            mode="single"
            className="[--cell-size:2.75rem]"
            locale={zhCN}
            selected={selected}
            defaultMonth={selected ?? today}
            captionLayout="dropdown"
            navLayout="after"
            startMonth={new Date(1900, 0)}
            endMonth={today}
            reverseYears
            disabled={{ before: new Date(1900, 0, 1), after: today }}
            onSelect={(nextDate) => {
              if (nextDate === undefined) return;
              onChange(formatBirthDate(nextDate));
              setOpen(false);
            }}
          />
        </PopoverContent>
      </Popover>
    </div>
  );
}
  • Step 5: Replace the native input without changing the patch contract

In birth-time-intake.tsx, import BirthDatePicker and replace the first <label>...</label> block with:

<BirthDatePicker
  value={value.date}
  disabled={isConfirmed}
  onChange={(date) => onPatch({ date })}
/>
  • Step 6: Run targeted contracts and TypeScript

Run:

node --test tests/birth-date-picker-contract.test.ts tests/birth-time-intake.test.ts
npx tsc --noEmit

Expected: all tests and TypeScript PASS.

  • Step 7: Commit the composition
git add frontend/DESIGN.md frontend/src/components/birth-date-picker.tsx frontend/src/components/birth-time-intake.tsx frontend/tests/birth-date-picker-contract.test.ts
git commit -m "feat: replace native birth date input"

Task 4: Real-surface QA and release gates

Files:

  • Modify only if QA finds a defect in the files owned by Tasks 13.

Interfaces:

  • Consumes: the complete BirthDatePicker flow.

  • Produces: browser evidence for selection, dismissal, range constraints, disabled state, and responsive layout.

  • Step 1: Run the full automated gates

npm test
npm run lint
npm run build

Expected: 100% test pass, zero lint errors, and a successful production build.

  • Step 2: Measure every modified TypeScript file
for file in src/lib/birth-time-intake-model.ts src/components/ui/calendar.tsx src/components/ui/popover.tsx src/components/birth-date-picker.tsx src/components/birth-time-intake.tsx tests/birth-date-picker-contract.test.ts tests/birth-time-intake.test.ts; do
  awk '!/^[[:space:]]*$/ && !/^[[:space:]]*(\/\/|#|--)/' "$file" | wc -l
done

Expected: each file is at or below 250 pure lines; no type escape hatch, parameter bloat, negative flag name, or unrelated helper is introduced.

  • Step 3: Verify the real interaction at 375px, 768px, and 1280px

At each width, use the in-app browser to:

  1. Open the birth date picker and confirm the popup stays inside the viewport.
  2. Confirm Chinese month/year dropdowns and keyboard focus are visible.
  3. Navigate to 1993, select April 17, and confirm the trigger displays 1993年4月17日.
  4. Confirm the popover closes and the controlled draft contains 1993-04-17.
  5. Reopen and confirm April 1993 remains selected.
  6. Confirm dates after today and before 1900 are unavailable.
  7. Load a confirmed profile and confirm the trigger cannot open.
  8. Confirm no browser-native date chooser appears.

Expected: every scenario passes with 44px targets, no clipping, no horizontal overflow, and no console errors.

  • Step 4: Run the visual QA dual-oracle gate

Provide fresh 375px, 768px, and 1280px evidence to two independent read-only reviewers. One reviews design-system fidelity and visual polish; the other reviews interaction, accessibility, and regression risk.

Expected: both reviewers return PASS. Fix any concrete issue and repeat only the affected evidence.

  • Step 5: Final diff hygiene
git diff --check
git status --short

Expected: no whitespace errors; unrelated pre-existing user changes remain untouched and are named in the handoff.