From b1aec8cd472a027452d52e59b0b6d7a95c343460 Mon Sep 17 00:00:00 2001 From: HerbertJulio Date: Tue, 30 Jun 2026 20:17:26 -0300 Subject: [PATCH] [ENG-46317] feat(webkit): evolve Calendar into a date-range picker Replace the inline month grid with a full date-range picker (composition, spec_version 3): a trigger that opens a CSS-positioned popover with a multi-month grid, Start/End date + time fields, a timezone selector, an Apply action, a presets rail, a clearable/split trigger, size variants, a horizontal layout, and a Select Period relative-time mode (parses 45m, last month, 1/1 - 1/2). Native Date + Intl only; no positioning or date libraries. --- .specs/calendar.md | 161 +++- .../inputs/calendar/Calendar.stories.js | 366 +++++++-- packages/webkit/package.json | 5 +- .../calendar-clear/calendar-clear.vue | 50 ++ .../calendar-fields/calendar-fields.vue | 158 ++++ .../calendar/calendar-grid/calendar-grid.vue | 390 +++++++++ .../calendar-period/calendar-period.vue | 101 +++ .../calendar-preset/calendar-preset.vue | 81 ++ .../calendar-timezone/calendar-timezone.vue | 58 ++ .../components/inputs/calendar/calendar.vue | 770 ++++++++++-------- .../src/components/inputs/calendar/format.ts | 171 ++++ .../src/components/inputs/calendar/index.ts | 34 + .../inputs/calendar/injection-key.ts | 75 ++ .../inputs/calendar/parse-period.ts | 215 +++++ 14 files changed, 2205 insertions(+), 430 deletions(-) create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-grid/calendar-grid.vue create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-period/calendar-period.vue create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-preset/calendar-preset.vue create mode 100644 packages/webkit/src/components/inputs/calendar/calendar-timezone/calendar-timezone.vue create mode 100644 packages/webkit/src/components/inputs/calendar/format.ts create mode 100644 packages/webkit/src/components/inputs/calendar/index.ts create mode 100644 packages/webkit/src/components/inputs/calendar/injection-key.ts create mode 100644 packages/webkit/src/components/inputs/calendar/parse-period.ts diff --git a/.specs/calendar.md b/.specs/calendar.md index 70b19ad38..2e69045a1 100644 --- a/.specs/calendar.md +++ b/.specs/calendar.md @@ -1,22 +1,22 @@ --- name: calendar category: inputs -structure: monolithic +structure: composition status: approved -spec_version: 1 +spec_version: 3 figma: url: https://www.figma.com/design/t97pXRs7xME3SJDs5iZ5RF/Webkit?node-id=5087-17336 node_id: 5087:17336 -checksum: b12599fa9619bc2e9b19e5e6011c7620099b2be7d92b9ff60e2ce8771bc425b1 +checksum: b1d756030feb2361958413f5d39fbddfebb9a8f652a8e5a98e7074eaf06d2894 created: 2026-06-25 -last_updated: 2026-06-25 +last_updated: 2026-06-30 --- # Calendar — Component Spec ## Purpose -Inline date picker that renders a month grid for selecting a single date or a date range. Unlike a popover-anchored datepicker, Calendar is always-visible (no overlay, no positioning) and is the standalone surface a consumer embeds in a form, panel, or popover body. Date math uses the native `Date` API only — no date library. +Date-range picker. A trigger button opens a popover that holds a one-click presets rail, one or more month grids, Start/End date + time fields, an optional timezone selector, and an Apply action; it also offers a "Select Period" mode whose text input parses relative spans (`45m`, `12 hours`, `last month`, `yesterday`, `1/1 - 1/2`). Selection is staged in a draft inside the popover and committed to `v-model` only on Apply (or immediately when `show-apply` is false). Date math uses the native `Date` API only — no date library; timezone is a selectable IANA label used for display formatting via `Intl` (the picker does not re-interpret `Date` objects across zones). ## Usage @@ -26,73 +26,149 @@ import { ref } from 'vue' import Calendar from '@aziontech/webkit/calendar' -const selected = ref(new Date(2026, 9, 8)) +const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + +const presets = [ + { label: 'Last 7 days', value: { start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) } }, + { label: 'Last 30 days', value: { start: new Date(2026, 8, 20), end: new Date(2026, 9, 19) } } +] + + + +``` + +The root renders the complete picker from props. For custom presets you may compose `` in the `presets` slot and a `` in the `footer` slot: + +```vue + ``` +Tree-shaking alternative — the standalone root + each sub-component from its own entry (no `Object.assign` compound pulled in): + +```vue + +``` + +## Sub-components + +- `calendar-preset/calendar-preset.vue` — context-aware shortcut button; applies its `value` (a `Date` in single mode or a `{ start, end }` range) to the popover's draft selection through injected context and reflects a `data-selected` state when its value matches. Placed in the `presets` slot. +- `calendar-clear/calendar-clear.vue` — context-aware button that clears the draft selection through injected context; disabled when there is no selection. Placed in the `footer` slot. + ## Props | Prop | Type | Default | Required | JSDoc | |---|---|---|---|---| -| `modelValue` | `Date \| null \| CalendarRange` | `null` | no | Selected value for v-model. A `Date` (or null) in single mode; a `{ start, end }` range object in range mode. | -| `mode` | `'single' \| 'range'` | `'single'` | no | Selection mode. Single picks one date; range picks a start and end date. | +| `modelValue` | `Date \| null \| CalendarRange` | `null` | no | Committed selection for v-model. A `Date` (or null) in single mode; a `{ start, end }` range in range mode. Only updated on Apply (or immediately when `showApply` is false). | +| `mode` | `'single' \| 'range'` | `'range'` | no | Selection mode. Single picks one date; range picks a start and end date. | +| `numberOfMonths` | `number` | `1` | no | Number of month grids rendered side-by-side; one shared previous/next pair pages the whole view by a month. | +| `size` | `'small' \| 'medium' \| 'large'` | `'medium'` | no | Size token; affects the trigger, day-cell hit-area, and typography. | | `min` | `Date \| undefined` | `undefined` | no | Earliest selectable date; earlier days render disabled. | | `max` | `Date \| undefined` | `undefined` | no | Latest selectable date; later days render disabled. | -| `disabled` | `boolean` | `false` | no | Disables the whole grid and navigation, applying disabled tokens. | -| `showHeader` | `boolean` | `true` | no | Shows the month/year label and previous/next month navigation. | +| `disabled` | `boolean` | `false` | no | Disables the trigger, grid, and all controls, applying disabled tokens. | +| `open` | `boolean \| undefined` | `undefined` | no | Controlled open state of the popover. Use with v-model:open; omit for uncontrolled. | +| `placeholder` | `string` | `'Select a Date Range'` | no | Trigger text shown when there is no selection. | +| `presets` | `CalendarPresetItem[]` | `[]` | no | Data-driven shortcuts rendered in the presets rail; each is `{ label, value }` where value is a `Date` or range. | +| `showTime` | `boolean` | `false` | no | Shows Start/End time fields alongside the date fields. | +| `showTimezone` | `boolean` | `false` | no | Shows the timezone selector below the fields. | +| `timezone` | `string` | `''` | no | Selected IANA timezone for display formatting (v-model:timezone). Empty resolves to the local zone. | +| `timezones` | `string[]` | `[]` | no | Timezone options for the selector; empty falls back to a curated list derived from `Intl`. | +| `horizontal` | `boolean` | `false` | no | Lays the fields/apply column beside the calendar instead of below it. | +| `clearable` | `boolean` | `false` | no | Shows a clear control on the trigger that empties the committed selection. | +| `showApply` | `boolean` | `true` | no | Stages edits in a draft and requires Apply to commit; when false, every edit commits immediately. | +| `period` | `boolean` | `false` | no | Enables the "Select Period" relative-time mode: a relative-preset list plus a text input that parses spans like `45m` or `last month`. | +| `split` | `boolean` | `false` | no | Renders the trigger as a split control with a separate chevron affordance. | ## Events | Event | Payload | Notes | |---|---|---| -| `update:modelValue` | `Date \| null \| CalendarRange` | v-model. The selected `Date` (single) or `{ start, end }` (range). | -| `month-change` | `CalendarMonth` | Emitted when the visible month changes via navigation or keyboard paging. Payload is `{ year, month }` (month is 0-indexed). | +| `update:modelValue` | `Date \| null \| CalendarRange` | v-model. The committed `Date` (single) or `{ start, end }` (range); empty value on clear. | +| `update:open` | `boolean` | v-model:open. Emitted when the popover opens or closes. | +| `update:timezone` | `string` | v-model:timezone. Emitted when the timezone selection changes. | +| `month-change` | `CalendarMonth` | Emitted when the first visible month changes via navigation or keyboard paging. Payload is `{ year, month }` (month is 0-indexed). | +| `apply` | `Date \| null \| CalendarRange` | Emitted when the draft selection is committed via Apply. | ## Slots -| _none_ | — | — | +| Slot | Scope | Notes | +|---|---|---| +| `trigger` | `{ open, value, displayValue }` | Replaces the default trigger button; scope exposes the open state, raw value, and formatted label. | +| `presets` | — | Custom presets rail content (e.g. ``); overrides the `presets` prop rendering. | +| `footer` | — | Custom footer/action-bar content (e.g. ``) alongside Apply. | ## States -- Visual states: `default`, `hover`, `focus-visible`, `selected`, `in-range`, `disabled`, `today`, `outside` (adjacent-month) -- `data-selected` mirrors a fully-selected day cell (single value, or a range endpoint) -- `data-band` on each day cell drives the connected range highlight: `none` | `single` | `start` | `middle` | `end` (endpoints round the outer edge; `middle` paints the in-range band edge-to-edge) -- `data-today` marks the current date -- `data-outside` marks an adjacent-month day cell -- `data-disabled` mirrors the `disabled` prop on the root and unselectable day cells +- Visual states: `default`, `hover`, `focus-visible`, `selected`, `in-range`, `disabled`, `today`, `outside` (adjacent-month), popover `open` / `closed` +- `data-state` on the root and trigger mirrors the popover open state (`open` | `closed`) +- `data-size` mirrors the `size` prop on the root, trigger, and each day cell (`small` | `medium` | `large`) +- `data-selected` mirrors a fully-selected day cell (single value, or a range endpoint) and an active preset +- `data-band` on each day cell drives the connected range highlight: `none` | `single` | `start` | `middle` | `end` +- `data-today` marks the current date; `data-outside` marks an adjacent-month day cell +- `data-disabled` mirrors the `disabled` prop on the root, unselectable day cells, and the clear control when there is no selection ## Motion & Animations | Trigger | Animation / Transition | Token (see `.claude/docs/DESIGN.md` § Animations) | Reduced-motion fallback | |---|---|---|---| +| popover open | `animate-popup-scale-in` | semantic (150ms · cubic-bezier) | `motion-reduce:animate-none` (instant) | +| popover close | `animate-popup-scale-out` | semantic (110ms · cubic-bezier) | `motion-reduce:animate-none` (instant) | | hover/focus state change | `transition-colors duration-150 ease-out` | inline (matches catalog) | `motion-reduce:transition-none` | ## Tokens | Region | Token (DESIGN.md) | |---|---| -| panel surface | `var(--bg-surface-raised)` | -| panel border | `var(--border-default)` | -| panel shape | `var(--shape-button)` | -| day cell shape | `var(--shape-elements)` | -| header typography | `.text-body-sm` | +| popover surface | `var(--bg-surface-raised)` | +| popover border | `var(--border-default)` | +| popover shape | `var(--shape-card)` | +| popover elevation | `var(--shadow-md)` | +| trigger / field surface | `var(--bg-surface)` | +| trigger / field border | `var(--border-default)` | +| trigger / field shape | `var(--shape-elements)` | +| section divider | `var(--border-default)` | +| label typography | `.text-label-sm` | | weekday typography | `.text-label-sm` | -| day typography | `.text-body-sm` | -| header text | `var(--text-default)` | -| weekday text | `var(--text-muted)` | -| day text (current month) | `var(--text-default)` | -| day text (adjacent month / disabled) | `var(--text-disabled)` | +| day typography (small) | `.text-body-xs` | +| day typography (medium) | `.text-body-sm` | +| day typography (large) | `.text-body-md` | +| body / value typography | `.text-body-sm` | +| header / muted text | `var(--text-muted)` | +| default text | `var(--text-default)` | +| disabled text | `var(--text-disabled)` | | selected day surface | `var(--secondary)` | | selected day text | `var(--secondary-contrast)` | | in-range band | `var(--bg-mask)` | | hover surface | `var(--bg-hover)` | -| nav button hover surface | `var(--bg-hover)` | | spacing (padding) | `var(--spacing-sm)` | | spacing (gap) | `var(--spacing-xxs)` | +| spacing (sections) | `var(--spacing-md)` | | ring | `var(--ring-color)` | ## Theme gaps @@ -103,17 +179,25 @@ const selected = ref(new Date(2026, 9, 8)) ## Accessibility (WCAG 2.1 AA) -- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-surface-raised)]` -- Keyboard map: `Tab` enters the grid at the focused day; `Arrow` keys move focus one day (left/right) or one week (up/down) with roving tabindex; `Enter`/`Space` selects the focused day; `PageUp`/`PageDown` navigate to the previous/next month; previous/next nav buttons are reachable by `Tab`. -- ARIA: grid uses `role="grid"` with `role="row"` and `role="gridcell"` descendants; selected cells set `aria-selected="true"`; the current date sets `aria-current="date"`; navigation buttons carry `aria-label` (previous month / next month); decorative chevron icons are `aria-hidden="true"`. +- Visible focus: `focus-visible:ring-2 focus-visible:ring-[var(--ring-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-canvas)]` on the trigger and `focus-visible:ring-offset-[var(--bg-surface-raised)]` inside the popover. +- Keyboard map: the trigger toggles the popover with `Enter`/`Space` and exposes `aria-haspopup`/`aria-expanded`; `Escape` and click-outside close it and return focus to the trigger; focus is trapped in the open popover. In the grid, `Tab` enters at the focused day, `Arrow` keys move focus (paging the visible window when crossing its first/last month), `Enter`/`Space` selects, `PageUp`/`PageDown` change month. Apply, Clear, presets, fields, and the timezone control are all reachable by `Tab`. +- ARIA: each month grid uses `role="grid"` with `role="row"` and `role="gridcell"` descendants; selected cells set `aria-selected="true"`; the current date sets `aria-current="date"`; navigation buttons carry `aria-label`; the popover is labelled by the trigger; decorative icons are `aria-hidden="true"`. - Contrast ≥4.5:1 (text) / ≥3:1 (large + icons), including the disabled state. -- `motion-reduce:transition-none` on animated states. -- Touch target ≥40×40 px (or justified deviation): day cells use a 36×36 px hit area matching the Figma `--size-9` token; this is a justified deviation for dense month-grid layouts. +- `motion-reduce:animate-none` on the popover and `motion-reduce:transition-none` on animated states. +- Touch target ≥40×40 px (or justified deviation): day cells use a 32×32 px (`small`), 36×36 px (`medium`), or 40×40 px (`large`) hit area; `small`/`medium` are a justified deviation for dense month-grid layouts, `large` meets the 40 px target. ## Stories (Storybook) -- Default — single mode with a preselected date. -- Range — range mode showing a selected start/end with the in-range band. Justified: range selection is a distinct, mutually-exclusive mode (`mode="range"`) with its own visual state (`data-band`) that the Default single-mode story cannot demonstrate. +- Default — range picker with a preselected range (trigger + popover). Justified: shows the baseline trigger→popover→grid flow. +- Sizes — composite story rendering every `size` value side-by-side (canonical, the component declares a `size` prop). +- Single — single-date mode (`mode="single"`). Justified: single is a distinct, mutually-exclusive mode the range default cannot show. +- MultiMonth — two months side-by-side (`number-of-months="2"`). Justified: a structural layout axis (shared nav, band across a month boundary) no other story exercises. +- Horizontal — `horizontal` layout with fields beside the calendar. Justified: a distinct layout the default vertical story cannot show. +- WithPresets — a `presets` rail of shortcuts. Justified: the data-driven preset behavior (one-click range + active state) cannot be shown without it. +- WithTime — `show-time` Start/End time fields. Justified: the time-field round-trip is a distinct capability. +- WithTimezone — `show-timezone` selector. Justified: the timezone control is a distinct capability. +- SelectPeriod — `period` relative-time mode (preset list + parsed text input). Justified: the relative-time parsing mode is mutually exclusive with the absolute calendar flow. +- Clearable — `clearable` trigger that empties the committed selection. Justified: the clear affordance and disabled-when-empty behavior cannot be shown without it. ## Constraints — DO NOT @@ -134,6 +218,7 @@ const selected = ref(new Date(2026, 9, 8)) - Do not duplicate the `## Usage` block from the spec inside the Storybook story body. The block is injected once into `parameters.docs.description.component` by the storybook-write skill; copy it nowhere else. - Do not edit `.claude/docs/DESIGN.md`, `.claude/docs/COMPONENT_REQUIREMENTS.md`, or `.claude/docs/PRIMEVUE_ABSTRACTION.md`. - Do not edit the root `package.json` or `.github/workflows/*`. +- Do not export composition sub-components without attaching them to the root compound (`index.ts` via `Object.assign`; vue-tsc generates `index.d.ts` — never hand-write it); the root export points at `index.ts`, and a standalone `./-root` export points at the root `.vue` (tree-shaking). Do not invent overlay part names (`Trigger` / `Content`) on a component with no `data-state=open|closed`, and do not collapse a slot-shaped concern into a config-array prop. See `.claude/rules/compound-api.md`. - Do not change `structure` after `status: approved`. To change structure, bump `spec_version` and re-author the spec. - Do not create files outside the paths declared by your task (the orchestrator tells you exactly which files to write). - Do not run `git` commands, `pnpm install`, or any command that changes the lockfile. diff --git a/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js b/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js index 593213412..be5b62dbd 100644 --- a/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js +++ b/apps/storybook/src/stories/components/inputs/calendar/Calendar.stories.js @@ -2,33 +2,29 @@ import { ref } from 'vue' import Calendar from '@aziontech/webkit/calendar' -const SINGLE_SOURCE = [ - '', - '', - '' -].join('\n') - -const RANGE_SOURCE = [ - '', - '', - '' -].join('\n') +const sfc = (body) => + ['', '', ''].join( + '\n' + ) + +const DEFAULT_SOURCE = sfc(' ') +const SIZES_SOURCE = sfc( + [ + '
', + ' ', + ' ', + ' ', + '
' + ].join('\n') +) +const SINGLE_SOURCE = sfc(' ') +const MULTI_MONTH_SOURCE = sfc(' ') +const HORIZONTAL_SOURCE = sfc(' ') +const PRESETS_SOURCE = sfc(' ') +const TIME_SOURCE = sfc(' ') +const TIMEZONE_SOURCE = sfc(' ') +const PERIOD_SOURCE = sfc(' ') +const CLEARABLE_SOURCE = sfc(' ') /** @type {import('@storybook/vue3').Meta} */ const meta = { @@ -49,7 +45,7 @@ const meta = { docs: { description: { component: - 'Inline date picker that renders a month grid for selecting a single date or a date range. Unlike a popover-anchored datepicker, Calendar is always-visible (no overlay, no positioning) and is the standalone surface a consumer embeds in a form, panel, or popover body. Date math uses the native `Date` API only — no date library.' + 'Date-range picker. A trigger button opens a popover that holds a one-click presets rail, one or more month grids, Start/End date + time fields, an optional timezone selector, and an Apply action; it also offers a "Select Period" mode whose text input parses relative spans (`45m`, `12 hours`, `last month`, `yesterday`, `1/1 - 1/2`). Selection is staged in a draft inside the popover and committed to `v-model` only on Apply (or immediately when `show-apply` is false). Date math uses the native `Date` API only — no date library.' }, canvas: { sourceState: 'shown' @@ -59,100 +55,360 @@ const meta = { argTypes: { modelValue: { control: false, - description: - 'Selected value for v-model. A Date (or null) in single mode; a range object in range mode.', + description: 'Committed selection for v-model (Date or range).', table: { category: 'props', type: { summary: 'Date | null | CalendarRange' } } }, mode: { control: 'inline-radio', options: ['single', 'range'], - description: 'Selection mode. Single picks one date; range picks a start and end date.', + description: 'Selection mode.', table: { category: 'props', type: { summary: "'single' | 'range'" }, - defaultValue: { summary: "'single'" } + defaultValue: { summary: "'range'" } + } + }, + numberOfMonths: { + control: { type: 'number', min: 1, max: 3 }, + description: 'Number of month grids rendered side-by-side.', + table: { category: 'props', type: { summary: 'number' }, defaultValue: { summary: '1' } } + }, + size: { + control: 'inline-radio', + options: ['small', 'medium', 'large'], + description: 'Size token; affects the trigger, day-cell hit-area, and typography.', + table: { + category: 'props', + type: { summary: "'small' | 'medium' | 'large'" }, + defaultValue: { summary: "'medium'" } } }, min: { control: false, - description: 'Earliest selectable date; earlier days render disabled.', + description: 'Earliest selectable date.', table: { category: 'props', type: { summary: 'Date' } } }, max: { control: false, - description: 'Latest selectable date; later days render disabled.', + description: 'Latest selectable date.', table: { category: 'props', type: { summary: 'Date' } } }, disabled: { control: 'boolean', - description: 'Disables the whole grid and navigation, applying disabled tokens.', + description: 'Disables the trigger, grid, and all controls.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + open: { + control: false, + description: 'Controlled open state of the popover (v-model:open).', + table: { category: 'props', type: { summary: 'boolean' } } + }, + placeholder: { + control: 'text', + description: 'Trigger text shown when there is no selection.', + table: { + category: 'props', + type: { summary: 'string' }, + defaultValue: { summary: "'Select date range'" } + } + }, + presets: { + control: false, + description: 'Data-driven shortcuts rendered in the presets rail.', + table: { category: 'props', type: { summary: 'CalendarPresetItem[]' } } + }, + showTime: { + control: 'boolean', + description: 'Shows Start/End time fields alongside the date fields.', table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } }, - showHeader: { + showTimezone: { control: 'boolean', - description: 'Shows the month/year label and previous/next month navigation.', + description: 'Shows the timezone selector below the fields.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + timezone: { + control: 'text', + description: 'Selected IANA timezone for display formatting (v-model:timezone).', + table: { category: 'props', type: { summary: 'string' }, defaultValue: { summary: "''" } } + }, + timezones: { + control: false, + description: 'Timezone options for the selector.', + table: { category: 'props', type: { summary: 'string[]' } } + }, + horizontal: { + control: 'boolean', + description: 'Lays the fields/apply column beside the calendar instead of below it.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + clearable: { + control: 'boolean', + description: 'Shows a clear control on the trigger that empties the committed selection.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + showApply: { + control: 'boolean', + description: 'Stages edits in a draft and requires Apply to commit; when false, commits immediately.', table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'true' } } }, + period: { + control: 'boolean', + description: 'Enables the Select Period relative-time mode.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, + split: { + control: 'boolean', + description: 'Renders the trigger as a split control with a separate chevron affordance.', + table: { category: 'props', type: { summary: 'boolean' }, defaultValue: { summary: 'false' } } + }, 'onUpdate:modelValue': { action: 'update:modelValue', - description: 'Emitted when the selection changes.', + description: 'Emitted when the selection is committed.', table: { category: 'events', type: { summary: 'Date | null | CalendarRange' } } }, + 'onUpdate:open': { + action: 'update:open', + description: 'Emitted when the popover opens or closes.', + table: { category: 'events', type: { summary: 'boolean' } } + }, + 'onUpdate:timezone': { + action: 'update:timezone', + description: 'Emitted when the timezone selection changes.', + table: { category: 'events', type: { summary: 'string' } } + }, onMonthChange: { action: 'month-change', - description: 'Emitted when the visible month changes via navigation or keyboard paging.', + description: 'Emitted when the first visible month changes.', table: { category: 'events', type: { summary: 'CalendarMonth' } } + }, + onApply: { + action: 'apply', + description: 'Emitted when the draft selection is committed via Apply.', + table: { category: 'events', type: { summary: 'Date | null | CalendarRange' } } } }, args: { - mode: 'single', + mode: 'range', + numberOfMonths: 1, + size: 'medium', disabled: false, - showHeader: true + showTime: false, + showTimezone: false, + horizontal: false, + clearable: false, + showApply: true, + period: false, + split: false } } export default meta +const handlers = (args) => { + const { onUpdateModelValue, onUpdateOpen, onUpdateTimezone, onMonthChange, onApply, ...props } = + args + return { props, onUpdateModelValue, onUpdateOpen, onUpdateTimezone, onMonthChange, onApply } +} + +const LISTENERS = + '@update:modelValue="onUpdateModelValue" @update:open="onUpdateOpen" @update:timezone="onUpdateTimezone" @month-change="onMonthChange" @apply="onApply"' + /** @type {import('@storybook/vue3').StoryObj} */ export const Default = { render: (args) => ({ components: { Calendar }, setup() { - const { onUpdateModelValue, onMonthChange, ...props } = args - const selected = ref(new Date(2026, 9, 8)) + const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: DEFAULT_SOURCE }, + description: { story: 'Range picker — the trigger opens a popover with the calendar grid and Apply.' } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Sizes = { + render: () => ({ + components: { Calendar }, + setup() { + const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + return { range } + }, + template: `
+ + + +
` + }), + parameters: { + docs: { + controls: { disable: true }, + source: { code: SIZES_SOURCE }, + description: { story: 'The three trigger sizes — `small`, `medium`, and `large`.' } + } + } +} - return { props, selected, onUpdateModelValue, onMonthChange } +/** @type {import('@storybook/vue3').StoryObj} */ +export const Single = { + render: (args) => ({ + components: { Calendar }, + setup() { + const date = ref(new Date(2026, 9, 8)) + return { ...handlers(args), date } }, - template: - '' + template: `` }), parameters: { docs: { source: { code: SINGLE_SOURCE }, - description: { story: 'Single-mode calendar with a preselected date.' } + description: { story: 'Single-date mode picks one day instead of a range.' } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const MultiMonth = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref({ start: new Date(2026, 9, 26), end: new Date(2026, 10, 8) }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: MULTI_MONTH_SOURCE }, + description: { + story: 'Two months side-by-side; one shared nav pages both and the range band spans the boundary.' + } } } } /** @type {import('@storybook/vue3').StoryObj} */ -export const Range = { +export const Horizontal = { render: (args) => ({ components: { Calendar }, setup() { - const { onUpdateModelValue, onMonthChange, ...props } = args - const selected = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: HORIZONTAL_SOURCE }, + description: { story: 'Horizontal layout places the Start/End fields and Apply beside the calendar.' } + } + } +} - return { props, selected, onUpdateModelValue, onMonthChange } +/** @type {import('@storybook/vue3').StoryObj} */ +export const WithPresets = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref({ start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) }) + const presets = [ + { label: 'Last 7 days', value: { start: new Date(2026, 9, 13), end: new Date(2026, 9, 19) } }, + { label: 'Last 30 days', value: { start: new Date(2026, 8, 20), end: new Date(2026, 9, 19) } } + ] + return { ...handlers(args), range, presets } }, - template: - '' + template: `` }), parameters: { docs: { - source: { code: RANGE_SOURCE }, + source: { code: PRESETS_SOURCE }, + description: { story: 'A presets rail of one-click shortcuts that stage the range in the draft.' } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const WithTime = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref({ + start: new Date(2026, 9, 8, 0, 0), + end: new Date(2026, 9, 19, 23, 59) + }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: TIME_SOURCE }, + description: { story: 'Start/End time fields round-trip the hours and minutes of each endpoint.' } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const WithTimezone = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref({ + start: new Date(2026, 9, 8, 0, 0), + end: new Date(2026, 9, 19, 23, 59) + }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: TIMEZONE_SOURCE }, + description: { story: 'A timezone selector (IANA zones) labels the selection; defaults to the local zone.' } + } + } +} + +/** @type {import('@storybook/vue3').StoryObj} */ +export const SelectPeriod = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref(null) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: PERIOD_SOURCE }, description: { - story: 'Range mode showing a selected start and end date with the in-range band between them.' + story: 'Select Period mode: a relative-preset list plus a text input that parses spans like `45m` or `last month`.' } } } } + +/** @type {import('@storybook/vue3').StoryObj} */ +export const Clearable = { + render: (args) => ({ + components: { Calendar }, + setup() { + const range = ref({ start: new Date(2026, 9, 8), end: new Date(2026, 9, 19) }) + return { ...handlers(args), range } + }, + template: `` + }), + parameters: { + docs: { + source: { code: CLEARABLE_SOURCE }, + description: { story: 'A clear control on the trigger empties the committed selection when present.' } + } + } +} diff --git a/packages/webkit/package.json b/packages/webkit/package.json index 070759875..2c63cd863 100644 --- a/packages/webkit/package.json +++ b/packages/webkit/package.json @@ -100,7 +100,10 @@ "./toast-close": "./src/components/feedback/toast/toast-close/toast-close.vue", "./skeleton": "./src/components/feedback/skeleton/skeleton.vue", "./progress-bar": "./src/components/feedback/progress-bar/progress-bar.vue", - "./calendar": "./src/components/inputs/calendar/calendar.vue", + "./calendar": "./src/components/inputs/calendar/index.ts", + "./calendar-root": "./src/components/inputs/calendar/calendar.vue", + "./calendar-preset": "./src/components/inputs/calendar/calendar-preset/calendar-preset.vue", + "./calendar-clear": "./src/components/inputs/calendar/calendar-clear/calendar-clear.vue", "./input-text": "./src/components/inputs/input-text/input-text.vue", "./input-number": "./src/components/inputs/input-number/input-number.vue", "./field-text": "./src/components/inputs/field-text/field-text.vue", diff --git a/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue b/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue new file mode 100644 index 000000000..d4d6c9e69 --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-clear/calendar-clear.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue b/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue new file mode 100644 index 000000000..8f5d7644c --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-fields/calendar-fields.vue @@ -0,0 +1,158 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar-grid/calendar-grid.vue b/packages/webkit/src/components/inputs/calendar/calendar-grid/calendar-grid.vue new file mode 100644 index 000000000..9a2389c0a --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-grid/calendar-grid.vue @@ -0,0 +1,390 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar-period/calendar-period.vue b/packages/webkit/src/components/inputs/calendar/calendar-period/calendar-period.vue new file mode 100644 index 000000000..8610e6b68 --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-period/calendar-period.vue @@ -0,0 +1,101 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar-preset/calendar-preset.vue b/packages/webkit/src/components/inputs/calendar/calendar-preset/calendar-preset.vue new file mode 100644 index 000000000..ac339956a --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-preset/calendar-preset.vue @@ -0,0 +1,81 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar-timezone/calendar-timezone.vue b/packages/webkit/src/components/inputs/calendar/calendar-timezone/calendar-timezone.vue new file mode 100644 index 000000000..5e5a688b7 --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/calendar-timezone/calendar-timezone.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/webkit/src/components/inputs/calendar/calendar.vue b/packages/webkit/src/components/inputs/calendar/calendar.vue index 6353596ec..a08299253 100644 --- a/packages/webkit/src/components/inputs/calendar/calendar.vue +++ b/packages/webkit/src/components/inputs/calendar/calendar.vue @@ -1,449 +1,547 @@ diff --git a/packages/webkit/src/components/inputs/calendar/format.ts b/packages/webkit/src/components/inputs/calendar/format.ts new file mode 100644 index 000000000..665d0c843 --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/format.ts @@ -0,0 +1,171 @@ +import type { CalendarMode, CalendarRange, CalendarValue } from './injection-key' + +/** Midnight of the given date (local). */ +export const startOfDay = (date: Date): Date => + new Date(date.getFullYear(), date.getMonth(), date.getDate()) + +/** Last representable minute of the given date (local), used as a range end default. */ +export const endOfDay = (date: Date): Date => + new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59) + +/** Same calendar day (ignores time). */ +export const sameDay = (a: Date | null | undefined, b: Date | null | undefined): boolean => { + if (!a || !b) { + return false + } + + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ) +} + +/** Narrow a model value to a range object (empty range when it is a Date or null). */ +export const asRange = (value: CalendarValue): CalendarRange => { + if (value && !(value instanceof Date)) { + return value + } + + return { start: null, end: null } +} + +/** Narrow a model value to a single Date (null when it is a range or null). */ +export const asSingle = (value: CalendarValue): Date | null => + value instanceof Date ? value : null + +/** Merge a day (Y/M/D) with a source time, defaulting to 00:00 (start) or 23:59 (end). */ +export const withTime = (date: Date, source: Date | null, isEnd: boolean): Date => { + const hours = source ? source.getHours() : isEnd ? 23 : 0 + const minutes = source ? source.getMinutes() : isEnd ? 59 : 0 + + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes) +} + +const tzOption = (timezone: string): string | undefined => timezone || undefined + +/** `Jun 30, 2026`. */ +export const formatDate = (date: Date, timezone = ''): string => + new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: tzOption(timezone) + }).format(date) + +/** `09:00 AM` (12h). */ +export const formatTime = (date: Date, timezone = ''): string => + new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + timeZone: tzOption(timezone) + }).format(date) + +const monthDay = (date: Date, timezone = ''): string => + new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + timeZone: tzOption(timezone) + }).format(date) + +/** + * Trigger label: a single date, a compact range (`Jun 9 – 26`, `Jun 9 – Jul 2`, + * `Dec 30, 2026 – Jan 2, 2027`), or `''` when there is no selection. + */ +export const formatValueLabel = ( + value: CalendarValue, + mode: CalendarMode, + timezone = '' +): string => { + if (mode !== 'range') { + const single = asSingle(value) + return single ? formatDate(single, timezone) : '' + } + + const { start, end } = asRange(value) + + if (!start && !end) { + return '' + } + if (start && !end) { + return `${formatDate(start, timezone)} –` + } + if (!start && end) { + return `– ${formatDate(end, timezone)}` + } + + const s = start as Date + const e = end as Date + const sameYear = s.getFullYear() === e.getFullYear() + const sameMonth = sameYear && s.getMonth() === e.getMonth() + + if (sameMonth) { + return `${monthDay(s, timezone)} – ${e.getDate()}` + } + if (sameYear) { + return `${monthDay(s, timezone)} – ${monthDay(e, timezone)}` + } + + return `${formatDate(s, timezone)} – ${formatDate(e, timezone)}` +} + +/** The host's local IANA timezone (e.g. `America/Sao_Paulo`). */ +export const localTimezone = (): string => { + try { + return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' + } catch { + return 'UTC' + } +} + +const cityName = (timezone: string): string => + timezone.split('/').pop()?.replace(/_/g, ' ') ?? timezone + +/** `UTC -05:00 (EST)` — the zone's UTC offset plus its abbreviation when alphabetic. */ +const offsetAndAbbr = (timezone: string): string => { + const reference = new Date() + const zone = tzOption(timezone) + const offsetRaw = + new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'longOffset' }) + .formatToParts(reference) + .find((part) => part.type === 'timeZoneName')?.value ?? '' + const offset = offsetRaw.replace('GMT', 'UTC ').trim() || 'UTC' + const abbr = + new Intl.DateTimeFormat('en-US', { timeZone: zone, timeZoneName: 'short' }) + .formatToParts(reference) + .find((part) => part.type === 'timeZoneName')?.value ?? '' + const hasAbbr = /[a-z]/i.test(abbr) && !abbr.startsWith('GMT') && !abbr.startsWith('UTC') + + return hasAbbr ? `${offset} (${abbr})` : offset +} + +/** `Local (UTC -05:00 (EST))` for the local zone, otherwise `City (UTC ±hh:mm (ABBR))`. */ +export const formatTimezoneLabel = (timezone: string): string => { + const resolved = timezone || localTimezone() + const prefix = resolved === localTimezone() ? 'Local' : cityName(resolved) + + return `${prefix} (${offsetAndAbbr(resolved)})` +} + +/** A curated timezone list (local first), derived from `Intl` when available. */ +export const defaultTimezones = (): string[] => { + const local = localTimezone() + const supported = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] }) + .supportedValuesOf + + const base = typeof supported === 'function' ? supported('timeZone') : [] + const curated = [ + local, + 'UTC', + 'America/New_York', + 'America/Sao_Paulo', + 'Europe/London', + 'Europe/Paris', + 'Asia/Tokyo' + ] + + const list = base.length > 0 ? [local, ...base] : curated + + return [...new Set(list)] +} diff --git a/packages/webkit/src/components/inputs/calendar/index.ts b/packages/webkit/src/components/inputs/calendar/index.ts new file mode 100644 index 000000000..ea3eaebbd --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/index.ts @@ -0,0 +1,34 @@ +/** + * Compound API — each sub-component stays available as its own import + * (`@aziontech/webkit/calendar-preset`, `@aziontech/webkit/calendar-clear`) and is + * also attached to the root for dot-notation usage: ``, ``. + * + * This is a `.ts` file so vue-tsc generates the adjacent `index.d.ts`, giving + * `` full type-checking. `Object.assign` keeps one source of truth; + * the explicit `CompoundCalendar` annotation lets declaration emit reference the + * sub-component types instead of expanding the root's private `Props`. + * See `.claude/rules/compound-api.md`. + */ +import Calendar from './calendar.vue' +import CalendarClear from './calendar-clear/calendar-clear.vue' +import CalendarPreset from './calendar-preset/calendar-preset.vue' + +type CompoundCalendar = typeof Calendar & { + Preset: typeof CalendarPreset + Clear: typeof CalendarClear +} + +const CalendarRoot = Object.assign(Calendar, { + Preset: CalendarPreset, + Clear: CalendarClear +}) as CompoundCalendar + +export type { + CalendarMode, + CalendarMonth, + CalendarRange, + CalendarSize, + CalendarValue +} from './injection-key' + +export default CalendarRoot diff --git a/packages/webkit/src/components/inputs/calendar/injection-key.ts b/packages/webkit/src/components/inputs/calendar/injection-key.ts new file mode 100644 index 000000000..4e8d656f6 --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/injection-key.ts @@ -0,0 +1,75 @@ +import type { ComputedRef, InjectionKey } from 'vue' + +/** Selection mode for the calendar grid. */ +export type CalendarMode = 'single' | 'range' + +/** Size token; affects the trigger, day-cell hit-area, and typography. */ +export type CalendarSize = 'small' | 'medium' | 'large' + +/** Range value emitted and accepted in range mode. */ +export interface CalendarRange { + start: Date | null + end: Date | null +} + +/** Visible month payload emitted on navigation. month is 0-indexed. */ +export interface CalendarMonth { + year: number + month: number +} + +/** v-model value: a Date (or null) in single mode, a range object in range mode. */ +export type CalendarValue = Date | null | CalendarRange + +/** A data-driven preset shortcut rendered in the presets rail. */ +export interface CalendarPresetItem { + label: string + value: Date | CalendarRange +} + +/** + * Shared state the root picker provides. Sub-components inject it and drive the + * popover's DRAFT selection (staged until Apply) so the consumer wires nothing. + */ +export interface CalendarContext { + /** Root data-testid; sub-components derive BEM-suffixed ids from it. */ + testId: string + /** Selection mode. */ + mode: ComputedRef + /** Active size token. */ + size: ComputedRef + /** Whether the whole picker is disabled. */ + disabled: ComputedRef + /** Number of month grids rendered side-by-side. */ + numberOfMonths: ComputedRef + /** Earliest selectable date (or undefined). */ + min: ComputedRef + /** Latest selectable date (or undefined). */ + max: ComputedRef + /** Whether Start/End time fields are shown. */ + showTime: ComputedRef + /** Whether the popover uses the horizontal (side-by-side) layout. */ + horizontal: ComputedRef + /** Staged (pre-Apply) selection the popover edits. */ + draft: ComputedRef + /** Whether the draft has any selection (drives the clear control). */ + hasSelection: ComputedRef + /** Selected IANA timezone (display only). */ + timezone: ComputedRef + /** Timezone options for the selector. */ + timezones: ComputedRef + /** Grid day click — range-building / single select on the draft. */ + selectDay: (date: Date) => void + /** Apply a full value (preset / period) to the draft. */ + selectValue: (value: Date | CalendarRange) => void + /** Set a range endpoint (or the single value) from the date/time fields. */ + setEndpoint: (which: 'start' | 'end', date: Date | null) => void + /** Clear the draft selection. */ + clear: () => void + /** Update the selected timezone. */ + setTimezone: (timezone: string) => void + /** Notify the root that the first visible month changed. */ + changeMonth: (month: CalendarMonth) => void +} + +export const CalendarInjectionKey: InjectionKey = Symbol('CalendarContext') diff --git a/packages/webkit/src/components/inputs/calendar/parse-period.ts b/packages/webkit/src/components/inputs/calendar/parse-period.ts new file mode 100644 index 000000000..cfa65b88b --- /dev/null +++ b/packages/webkit/src/components/inputs/calendar/parse-period.ts @@ -0,0 +1,215 @@ +import { endOfDay, startOfDay } from './format' +import type { CalendarRange } from './injection-key' + +/** + * Parses a relative or fixed period expression into a `{ start, end }` range, or + * `null` when it cannot be understood. Pure native-`Date` math, no date library. + * + * Accepted forms (case-insensitive): + * - relative spans: `45m`, `12 hours`, `10d`, `2 weeks`, `3 months`, `1y` → [now − span, now] + * - keywords: `today`, `yesterday`, `last week`, `last month`, `last year`, `this month` + * - fixed single dates: `1/1`, `1/1/2026`, `Jan 1`, `Jan 1, 2026` → that whole day + * - fixed ranges: `1/1 - 1/2`, `Jan 1 - Jan 2` (separators `-`, `–`, `to`) + */ +export function parsePeriod(input: string, now: Date = new Date()): CalendarRange | null { + const text = input.trim().toLowerCase() + + if (!text) { + return null + } + + const keyword = parseKeyword(text, now) + if (keyword) { + return keyword + } + + const relative = parseRelative(text, now) + if (relative) { + return relative + } + + const rangeParts = splitRange(text) + if (rangeParts) { + const start = parseFixedDate(rangeParts[0], now) + const end = parseFixedDate(rangeParts[1], now) + if (start && end) { + return { start: startOfDay(start), end: endOfDay(end) } + } + return null + } + + const single = parseFixedDate(text, now) + if (single) { + return { start: startOfDay(single), end: endOfDay(single) } + } + + return null +} + +const MS_PER_MINUTE = 60_000 +const MS_PER_HOUR = 3_600_000 +const MS_PER_DAY = 86_400_000 + +const UNIT_MS: Record = { + m: MS_PER_MINUTE, + min: MS_PER_MINUTE, + mins: MS_PER_MINUTE, + minute: MS_PER_MINUTE, + minutes: MS_PER_MINUTE, + h: MS_PER_HOUR, + hr: MS_PER_HOUR, + hrs: MS_PER_HOUR, + hour: MS_PER_HOUR, + hours: MS_PER_HOUR, + d: MS_PER_DAY, + day: MS_PER_DAY, + days: MS_PER_DAY, + w: MS_PER_DAY * 7, + wk: MS_PER_DAY * 7, + week: MS_PER_DAY * 7, + weeks: MS_PER_DAY * 7 +} + +const CALENDAR_UNITS: Record = { + mo: 'month', + month: 'month', + months: 'month', + y: 'year', + yr: 'year', + year: 'year', + years: 'year' +} + +function parseRelative(text: string, now: Date): CalendarRange | null { + const match = text.match(/^(\d+)\s*([a-z]+)$/) + if (!match) { + return null + } + + const amount = Number(match[1]) + const unit = match[2] + + if (unit in UNIT_MS) { + return { start: new Date(now.getTime() - amount * UNIT_MS[unit]), end: new Date(now.getTime()) } + } + + if (unit in CALENDAR_UNITS) { + const start = new Date(now) + if (CALENDAR_UNITS[unit] === 'month') { + start.setMonth(start.getMonth() - amount) + } else { + start.setFullYear(start.getFullYear() - amount) + } + return { start, end: new Date(now.getTime()) } + } + + return null +} + +function parseKeyword(text: string, now: Date): CalendarRange | null { + switch (text) { + case 'today': + return { start: startOfDay(now), end: endOfDay(now) } + case 'yesterday': { + const y = new Date(now.getTime() - MS_PER_DAY) + return { start: startOfDay(y), end: endOfDay(y) } + } + case 'last week': + return { start: new Date(now.getTime() - 7 * MS_PER_DAY), end: new Date(now.getTime()) } + case 'last month': { + const start = new Date(now) + start.setMonth(start.getMonth() - 1) + return { start, end: new Date(now.getTime()) } + } + case 'last year': { + const start = new Date(now) + start.setFullYear(start.getFullYear() - 1) + return { start, end: new Date(now.getTime()) } + } + case 'this month': + return { + start: new Date(now.getFullYear(), now.getMonth(), 1), + end: endOfDay(new Date(now.getFullYear(), now.getMonth() + 1, 0)) + } + default: + return null + } +} + +function splitRange(text: string): [string, string] | null { + const match = text.split(/\s*(?:-|–|to)\s+|\s+(?:-|–|to)\s*/) + if (match.length === 2 && match[0] && match[1]) { + return [match[0].trim(), match[1].trim()] + } + return null +} + +const MONTHS: Record = { + jan: 0, + january: 0, + feb: 1, + february: 1, + mar: 2, + march: 2, + apr: 3, + april: 3, + may: 4, + jun: 5, + june: 5, + jul: 6, + july: 6, + aug: 7, + august: 7, + sep: 8, + sept: 8, + september: 8, + oct: 9, + october: 9, + nov: 10, + november: 10, + dec: 11, + december: 11 +} + +/** Parses a single date token (`M/D`, `M/D/Y`, `Jan 1`, `Jan 1, 2026`) to a Date, or null. */ +export function parseFixedDate(token: string, now: Date = new Date()): Date | null { + const text = token.trim().toLowerCase() + if (!text) { + return null + } + + // M/D or M/D/Y + const slash = text.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/) + if (slash) { + const month = Number(slash[1]) - 1 + const day = Number(slash[2]) + const year = slash[3] ? normalizeYear(Number(slash[3])) : now.getFullYear() + return validDate(year, month, day) + } + + // "Jan 1" or "Jan 1, 2026" or "January 1 2026" + const named = text.match(/^([a-z]+)\.?\s+(\d{1,2})(?:,?\s*(\d{2,4}))?$/) + if (named && named[1] in MONTHS) { + const month = MONTHS[named[1]] + const day = Number(named[2]) + const year = named[3] ? normalizeYear(Number(named[3])) : now.getFullYear() + return validDate(year, month, day) + } + + return null +} + +function normalizeYear(year: number): number { + return year < 100 ? 2000 + year : year +} + +function validDate(year: number, month: number, day: number): Date | null { + if (month < 0 || month > 11 || day < 1 || day > 31) { + return null + } + const date = new Date(year, month, day) + if (date.getMonth() !== month || date.getDate() !== day) { + return null + } + return date +}