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

440 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.