docs: plan birth date picker
This commit is contained in:
@@ -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 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**
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user