docs: plan birth date picker

This commit is contained in:
Jesse_Chen
2026-07-17 20:58:13 +08:00
parent 6ffb9cf0c5
commit b0eb52f792
@@ -0,0 +1,439 @@
# 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:
```ts
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:
```bash
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:
```bash
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`:
```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:
```bash
TZ=America/Los_Angeles node --test tests/birth-time-intake.test.ts
```
Expected: all birth-time intake tests PASS.
- [ ] **Step 6: Commit the adapter**
```bash
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`:
```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:
```bash
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/`:
```bash
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:
```bash
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:
```bash
node --test tests/birth-date-picker-contract.test.ts
npx tsc --noEmit
```
Expected: both commands PASS.
- [ ] **Step 6: Commit the primitives**
```bash
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:
```md
### 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`:
```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:
```bash
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:
```tsx
"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:
```tsx
<BirthDatePicker
value={value.date}
disabled={isConfirmed}
onChange={(date) => onPatch({ date })}
/>
```
- [ ] **Step 6: Run targeted contracts and TypeScript**
Run:
```bash
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**
```bash
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**
```bash
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**
```bash
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**
```bash
git diff --check
git status --short
```
Expected: no whitespace errors; unrelated pre-existing user changes remain untouched and are named in the handoff.