15 KiB
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 />}plusPopoverContentcontaining a single-selectCalendar. - Date values remain
YYYY-MM-DD; the API payload and database schema do not change. - The selectable range is
1900-01-01through 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.mdtokens 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, andisValid -
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, andPopoverContent -
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)andformatBirthDate(value: Date)from Task 1. -
Consumes:
Calendar,Popover,PopoverContent,PopoverTrigger,Button,CalendarIcon, andzhCN. -
Produces:
BirthDatePicker({ value, disabled, onChange })wherevalueisYYYY-MM-DDandonChangereceivesYYYY-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 1–3.
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:
- Open the birth date picker and confirm the popup stays inside the viewport.
- Confirm Chinese month/year dropdowns and keyboard focus are visible.
- Navigate to 1993, select April 17, and confirm the trigger displays
1993年4月17日. - Confirm the popover closes and the controlled draft contains
1993-04-17. - Reopen and confirm April 1993 remains selected.
- Confirm dates after today and before 1900 are unavailable.
- Load a confirmed profile and confirm the trigger cannot open.
- 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.