mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-27 22:45:03 +00:00
feat(dates): restore a calendar picker behind DateInput
Dropping `<input type="date">` for the dd/mm/yyyy mask also dropped its
calendar, leaving typing as the only way to enter a date. ADR-0019 accepted
that cost but named the remedy: add a picker *behind* `DateInput`, writing the
same ISO value, rather than revert to the native control. That was felt
immediately in use, so this does it.
`DateInput` now renders a calendar button inside the field's right edge and a
Calendar in a Radix popover. Typing and picking are equal routes to the same
value — picking fills the text, typing moves the grid — so neither is
privileged and the two cannot disagree. The trigger is `type="button"` so it
cannot submit the form, and carries an aria-label, a bare icon having no
accessible name.
The calendar wrapper is written by hand rather than taken from the shadcn
registry: the published template targets react-day-picker v8/v9, whose
`classNames` keys differ from the `UI` enum v10 uses. It defaults to the enGB
locale, which carries `weekStartsOn: 1`, so the grid starts on Monday without
being told to.
The delicate part is the `Date` boundary. A calendar works in `Date`s, and
both obvious conversions are wrong: `new Date("2026-03-01")` is midnight UTC,
which is 29 February west of Greenwich, and `toISOString()` shifts the day back
the other way. So `isoToLocalDate` and `localDateToIso` build and read local
parts, and they are the only two functions in `src/utils/dates.ts` that touch
`Date` at all — everything else stays string-only. The round trip is tested
across a full month rather than at a sample day, because the failure only
appears on some days in some zones; the suite was also run under
TZ=America/Los_Angeles and TZ=Pacific/Auckland.
Dependencies: react-day-picker and date-fns, the latter already present
transitively and now direct at v4. `@tremor/react` bundles react-day-picker v8
nested and npm kept it isolated on date-fns v3, so nothing existing changed
version; no Tremor DatePicker is used anywhere in src, so there is no second
copy in the bundle. The alternative was a hand-rolled month grid with no
dependency, rejected because keyboard navigation and ARIA on a calendar are
easy to get subtly wrong and not worth owning.
ADR-0019's "the native calendar picker is gone" consequence was now false and
has been rewritten.
TESTS: tsc --noEmit and ESLint clean; vitest 582 passing (53 files), 24 of them
for the date helpers. NOT verified in a browser — this project has no jsdom or
testing-library, and `next build` was not run because it would statically
render pages against a DATABASE_URL still pointing at production. The new
Cypress case, which picks 17 March from the grid and expects 17/03/2026 in the
field, is unrun like the rest of that spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1019e3ea3f
commit
c96d964d24
9 changed files with 349 additions and 34 deletions
|
|
@ -18,7 +18,8 @@
|
|||
- **Never `<input type="date">`.** Its format follows the *browser's* locale,
|
||||
so a US-configured browser shows `mm/dd/yyyy` and no attribute overrides it.
|
||||
Use `DateInput` (`@/app/components/DateInput`) instead — it masks to
|
||||
`dd/mm/yyyy` and its `value`/`onChange` stay `YYYY-MM-DD`.
|
||||
`dd/mm/yyyy`, carries a calendar picker, and its `value`/`onChange` stay
|
||||
`YYYY-MM-DD`.
|
||||
- **`YYYY-MM-DD` is the only format that crosses a boundary** — form state,
|
||||
request bodies, Drizzle `date` columns. The display format stops at the
|
||||
input's edge. Parsing/formatting helpers are in `@/utils/dates`; for output,
|
||||
|
|
|
|||
|
|
@ -92,6 +92,28 @@ describe("Ara Projects — create project (stubbed)", function () {
|
|||
);
|
||||
});
|
||||
|
||||
it("fills the field day-first from the calendar picker", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
||||
// Type a date first so the grid opens on a known month rather than
|
||||
// whatever month the test happens to run in.
|
||||
cy.get("[data-testid=create-project-start-date]").type("01/03/2026");
|
||||
cy.get("[data-testid=create-project-start-date-picker]").click();
|
||||
|
||||
// The grid is en-GB, so the week starts on Monday.
|
||||
cy.get("[role=dialog]").within(() => {
|
||||
cy.contains("Mo").should("be.visible");
|
||||
cy.contains("March 2026").should("be.visible");
|
||||
cy.get("[role=gridcell] button").contains(/^17$/).click();
|
||||
});
|
||||
|
||||
cy.get("[data-testid=create-project-start-date]").should(
|
||||
"have.value",
|
||||
"17/03/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats the date as it is typed and rejects a day that does not exist", function () {
|
||||
cy.visit("/projects");
|
||||
cy.get("[data-testid=create-project-trigger]").click();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ The options were:
|
|||
"Start date (dd/mm/yyyy)". Honest, but a lie on a US-locale browser, where
|
||||
the box then contradicts its own label.
|
||||
3. **Replace the native control with a masked text input** that reads and
|
||||
writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value.
|
||||
writes `dd/mm/yyyy`, keeping `YYYY-MM-DD` as the value, and supply our own
|
||||
calendar to replace the native one it displaces.
|
||||
|
||||
## Decision
|
||||
|
||||
|
|
@ -50,9 +51,10 @@ every user, regardless of their browser's locale.
|
|||
|
||||
**Date form fields use `DateInput` (`src/app/components/DateInput.tsx`), not
|
||||
`<input type="date">`.** It is a text input that masks to `dd/mm/yyyy` as the
|
||||
user types, and whose `value`/`onChange` remain `YYYY-MM-DD`. The parsing and
|
||||
formatting are pure functions in `src/utils/dates.ts` (`formatUkDate`,
|
||||
`parseUkDate`, `formatUkDateWhileTyping`), unit-tested independently of React.
|
||||
user types, with a calendar picker in a popover, and whose `value`/`onChange`
|
||||
remain `YYYY-MM-DD`. The parsing and formatting are pure functions in
|
||||
`src/utils/dates.ts` (`formatUkDate`, `parseUkDate`, `formatUkDateWhileTyping`,
|
||||
`isoToLocalDate`, `localDateToIso`), unit-tested independently of React.
|
||||
|
||||
**`YYYY-MM-DD` remains the only format that crosses a boundary** — form state,
|
||||
request bodies, Drizzle `date` columns. The display format stops at the input's
|
||||
|
|
@ -65,11 +67,26 @@ what rendering already did.
|
|||
|
||||
## Consequences
|
||||
|
||||
- **The native calendar picker is gone** from fields that use `DateInput`. This
|
||||
is the real cost, and it is accepted: a picker is a convenience, whereas an
|
||||
ambiguous entry format is a correctness problem. If a picker is wanted later,
|
||||
it should be added *behind* `DateInput` — a popover calendar writing the same
|
||||
ISO value — not by reverting to the native control.
|
||||
- **The picker is replaced, not lost.** Dropping the native control also drops
|
||||
its calendar, which was judged an acceptable cost — a picker is a
|
||||
convenience, an ambiguous entry format is a correctness problem — but the
|
||||
loss was immediately felt in use, so the calendar was rebuilt *behind*
|
||||
`DateInput` as this ADR anticipated: a popover writing the same ISO value,
|
||||
rather than a revert to the native control. It uses `react-day-picker` via
|
||||
`shadcn_components/ui/calendar.tsx`, defaulting to the `enGB` locale, whose
|
||||
`weekStartsOn: 1` puts Monday first. Typing and picking are equal routes to
|
||||
the same value.
|
||||
- **Two new dependencies**: `react-day-picker` and `date-fns` (the latter was
|
||||
already present transitively; it is now direct). The alternative was a
|
||||
hand-rolled month grid with no dependency, rejected because keyboard
|
||||
navigation and ARIA on a calendar are easy to get subtly wrong and not worth
|
||||
owning.
|
||||
- **The `Date` boundary is confined to two functions.** A calendar works in
|
||||
`Date`s, so `isoToLocalDate` / `localDateToIso` bridge to them — building and
|
||||
reading *local* parts, never `new Date(iso)` or `toISOString()`, both of
|
||||
which shift the day across midnight for a non-zero UTC offset. Everything
|
||||
else in `src/utils/dates.ts` stays string-only. The round trip is tested
|
||||
across a full month.
|
||||
- **Caret handling is a masked input's usual compromise.** Typing at the end of
|
||||
the field, which is the overwhelming case, behaves normally; editing in the
|
||||
middle can move the caret to the end. Judged not worth a masking dependency.
|
||||
|
|
|
|||
60
package-lock.json
generated
60
package-lock.json
generated
|
|
@ -46,6 +46,7 @@
|
|||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"esbuild": "^0.25.8",
|
||||
"eslint-config-next": "13.4.3",
|
||||
|
|
@ -61,6 +62,7 @@
|
|||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"recharts": "^3.8.1",
|
||||
|
|
@ -1250,6 +1252,12 @@
|
|||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz",
|
||||
"integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
|
|
@ -6036,12 +6044,36 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/react-day-picker": {
|
||||
"version": "8.10.2",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.2.tgz",
|
||||
"integrity": "sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tremor/react/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
@ -8528,9 +8560,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz",
|
||||
"integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -13283,17 +13315,29 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "8.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz",
|
||||
"integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "^1.4.1",
|
||||
"date-fns": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"date-fns": "^2.28.0 || ^3.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
"@types/react": ">=16.8.0",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"aws-sdk": "^2.1415.0",
|
||||
"class-variance-authority": "^0.6.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"esbuild": "^0.25.8",
|
||||
"eslint-config-next": "13.4.3",
|
||||
|
|
@ -73,6 +74,7 @@
|
|||
"postcss": "^8.5.6",
|
||||
"react": "18.3.1",
|
||||
"react-confetti": "^6.4.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"recharts": "^3.8.1",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CalendarDaysIcon } from "@heroicons/react/24/outline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Calendar } from "@/app/shadcn_components/ui/calendar";
|
||||
import { Input } from "@/app/shadcn_components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/app/shadcn_components/ui/popover";
|
||||
import {
|
||||
formatUkDate,
|
||||
formatUkDateWhileTyping,
|
||||
isoToLocalDate,
|
||||
localDateToIso,
|
||||
parseUkDate,
|
||||
} from "@/utils/dates";
|
||||
|
||||
|
|
@ -25,6 +35,8 @@ export interface DateInputProps
|
|||
* it — see the note in the component body.
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/** Cypress hook. The calendar trigger derives its own from this, suffixed. */
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -33,8 +45,11 @@ export interface DateInputProps
|
|||
* Use this rather than `<input type="date">` anywhere a user enters a date.
|
||||
* The native control's display format follows the *browser's* locale, not the
|
||||
* document's — so a US-configured browser shows `mm/dd/yyyy` on a UK product,
|
||||
* with no attribute available to override it. The cost is losing the native
|
||||
* calendar picker; ADR-0019 records why that trade is the right way round.
|
||||
* with no attribute available to override it.
|
||||
*
|
||||
* A date can be typed or picked. The calendar in the popover replaces the one
|
||||
* lost with the native control, and writes through the same value, so neither
|
||||
* route is privileged: picking fills the text, typing moves the grid.
|
||||
*
|
||||
* The value crossing this boundary is always `YYYY-MM-DD`, so form state,
|
||||
* request bodies and `date` columns are unaffected — only what the user sees
|
||||
|
|
@ -47,6 +62,8 @@ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
|
|||
// text mid-keystroke, because typing reports `""` for incomplete input.
|
||||
const current = value ?? "";
|
||||
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
|
||||
// The displayed text and the value it produced, held together so the pair
|
||||
// can never disagree. Half-typed text ("03/0") has no ISO form, so the
|
||||
// field cannot be driven by `value` alone.
|
||||
|
|
@ -83,20 +100,67 @@ export const DateInput = React.forwardRef<HTMLInputElement, DateInputProps>(
|
|||
onChange(next);
|
||||
}
|
||||
|
||||
function handleSelect(date: Date | undefined) {
|
||||
const next = date ? localDateToIso(date) : "";
|
||||
setState({ text: formatUkDate(next), value: next });
|
||||
onChange(next);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
|
||||
// The date the grid should highlight and open on. A half-typed or
|
||||
// impossible entry has none, in which case the picker opens on the current
|
||||
// month rather than refusing to open.
|
||||
const selected = isoToLocalDate(state.value) ?? undefined;
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
// `text`, not `date` — see the component's docs.
|
||||
type="text"
|
||||
// Phones get the number pad; the mask supplies the separators.
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="dd/mm/yyyy"
|
||||
maxLength={10}
|
||||
value={state.text}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...props}
|
||||
ref={ref}
|
||||
// `text`, not `date` — see the component's docs.
|
||||
type="text"
|
||||
// Phones get the number pad; the mask supplies the separators.
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="dd/mm/yyyy"
|
||||
maxLength={10}
|
||||
value={state.text}
|
||||
onChange={handleChange}
|
||||
className={cn("pr-10", props.className)}
|
||||
/>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
// Typing stays the primary interaction, so the trigger sits
|
||||
// inside the field rather than beside it: the input keeps the
|
||||
// full width of its grid column, and the layout is unchanged
|
||||
// from the native control it replaced.
|
||||
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={props.disabled}
|
||||
// The field itself is labelled; this button is not, and a bare
|
||||
// icon has no accessible name without one.
|
||||
aria-label="Choose a date from the calendar"
|
||||
data-testid={
|
||||
props["data-testid"]
|
||||
? `${props["data-testid"]}-picker`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CalendarDaysIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
defaultMonth={selected}
|
||||
autoFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
|||
82
src/app/shadcn_components/ui/calendar.tsx
Normal file
82
src/app/shadcn_components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { DayPicker, type DayPickerProps } from "react-day-picker";
|
||||
import { enGB } from "react-day-picker/locale";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/app/shadcn_components/ui/button";
|
||||
|
||||
export type CalendarProps = DayPickerProps;
|
||||
|
||||
/**
|
||||
* The shadcn calendar, on react-day-picker v10.
|
||||
*
|
||||
* Written by hand rather than pulled from the shadcn registry: the published
|
||||
* template targets v8/v9, whose `classNames` keys differ from the `UI` enum
|
||||
* this version uses. The keys below are v10's.
|
||||
*
|
||||
* Defaults to `enGB`, which is the reason this wrapper exists rather than
|
||||
* `DayPicker` being used directly — the locale carries `weekStartsOn: 1`, so
|
||||
* the grid starts on Monday and the weekday headers read Mo–Su, matching how
|
||||
* the rest of the app formats dates.
|
||||
*/
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
locale = enGB,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
locale={locale}
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row gap-4",
|
||||
month: "flex flex-col gap-4",
|
||||
month_caption: "flex justify-center pt-1 relative items-center h-7",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "flex items-center gap-1 absolute right-1 top-1 z-10",
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
month_grid: "w-full border-collapse space-y-1",
|
||||
weekdays: "flex",
|
||||
weekday:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
week: "flex w-full mt-2",
|
||||
day: "h-9 w-9 text-center text-sm p-0 relative focus-within:relative focus-within:z-20",
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
selected:
|
||||
"[&>button]:bg-primary [&>button]:text-primary-foreground [&>button:hover]:bg-primary [&>button:focus]:bg-primary",
|
||||
today: "[&>button]:bg-accent [&>button]:text-accent-foreground",
|
||||
outside: "[&>button]:text-muted-foreground [&>button]:opacity-50",
|
||||
disabled: "[&>button]:text-muted-foreground [&>button]:opacity-50",
|
||||
hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation, className: chevronClassName, ...rest }) => {
|
||||
const Icon =
|
||||
orientation === "left" ? ChevronLeftIcon : ChevronRightIcon;
|
||||
return <Icon className={cn("h-4 w-4", chevronClassName)} {...rest} />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Calendar.displayName = "Calendar";
|
||||
|
||||
export { Calendar };
|
||||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatUkDate, formatUkDateWhileTyping, parseUkDate } from "./dates";
|
||||
import {
|
||||
formatUkDate,
|
||||
formatUkDateWhileTyping,
|
||||
isoToLocalDate,
|
||||
localDateToIso,
|
||||
parseUkDate,
|
||||
} from "./dates";
|
||||
|
||||
describe("formatUkDate", () => {
|
||||
it("renders an ISO date day-first", () => {
|
||||
|
|
@ -66,6 +72,49 @@ describe("parseUkDate", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("isoToLocalDate / localDateToIso", () => {
|
||||
it("builds local midnight, not UTC midnight", () => {
|
||||
const date = isoToLocalDate("2026-03-01")!;
|
||||
expect(date.getFullYear()).toBe(2026);
|
||||
expect(date.getMonth()).toBe(2); // zero-based: March
|
||||
expect(date.getDate()).toBe(1);
|
||||
expect(date.getHours()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null for anything that is not a real ISO date", () => {
|
||||
expect(isoToLocalDate("")).toBeNull();
|
||||
expect(isoToLocalDate(null)).toBeNull();
|
||||
expect(isoToLocalDate(undefined)).toBeNull();
|
||||
expect(isoToLocalDate("01/03/2026")).toBeNull();
|
||||
expect(isoToLocalDate("2026-02-31")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the local parts back", () => {
|
||||
expect(localDateToIso(new Date(2026, 2, 1))).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
it("pads single-digit months and days", () => {
|
||||
expect(localDateToIso(new Date(2026, 0, 5))).toBe("2026-01-05");
|
||||
});
|
||||
|
||||
it("round-trips every day of a month regardless of the host timezone", () => {
|
||||
// The bug this guards is a date shifting by one day through a UTC
|
||||
// conversion, which only shows up on some days in some zones — so check
|
||||
// them all rather than a single sample.
|
||||
for (let day = 1; day <= 31; day++) {
|
||||
const iso = `2026-03-${String(day).padStart(2, "0")}`;
|
||||
expect(localDateToIso(isoToLocalDate(iso)!)).toBe(iso);
|
||||
}
|
||||
});
|
||||
|
||||
it("survives a date whose UTC form is the previous day", () => {
|
||||
// 1 March at local midnight is 28 February in UTC for any negative
|
||||
// offset. toISOString() would report the 28th; this must not.
|
||||
const date = new Date(2026, 2, 1);
|
||||
expect(localDateToIso(date)).toBe("2026-03-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatUkDateWhileTyping", () => {
|
||||
const typed = (raw: string) => formatUkDateWhileTyping(raw, { deleting: false });
|
||||
const deleted = (raw: string) => formatUkDateWhileTyping(raw, { deleting: true });
|
||||
|
|
|
|||
|
|
@ -78,6 +78,40 @@ export function parseUkDate(text: string): string | null {
|
|||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` → a `Date` at **local** midnight, for the calendar picker.
|
||||
*
|
||||
* This is the one place a `Date` is unavoidable — a calendar component works
|
||||
* in `Date`s — so it is also the one place the timezone trap has to be
|
||||
* handled. `new Date("2026-03-01")` parses as midnight *UTC*, which west of
|
||||
* Greenwich is the evening of 29 February, and a calendar comparing it against
|
||||
* local days would highlight the wrong one. Passing the parts separately
|
||||
* builds local midnight instead, which is what the grid compares against.
|
||||
*
|
||||
* Returns `null` for anything that is not a real ISO date.
|
||||
*/
|
||||
export function isoToLocalDate(iso: string | null | undefined): Date | null {
|
||||
const match = iso?.match(ISO_DATE);
|
||||
if (!match) return null;
|
||||
const [, year, month, day] = match;
|
||||
if (!isRealDate(Number(year), Number(month), Number(day))) return null;
|
||||
return new Date(Number(year), Number(month) - 1, Number(day));
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Date` → `YYYY-MM-DD`, reading the **local** parts.
|
||||
*
|
||||
* The inverse of {@link isoToLocalDate}, and it must not go via `toISOString`,
|
||||
* which converts to UTC and would shift the date across midnight for any
|
||||
* non-zero offset — the same bug in the opposite direction.
|
||||
*/
|
||||
export function localDateToIso(date: Date): string {
|
||||
const year = String(date.getFullYear()).padStart(4, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reformat a half-typed `dd/mm/yyyy` field after every keystroke: keep the
|
||||
* digits, drop everything else, and regroup.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue