Skip to content

feat: CalendarPreview scale-aware selection - #898

Open
Shreyag02 wants to merge 6 commits into
feat/calendar-preview-rangepickerfrom
feat/calendar-preview-scale
Open

feat: CalendarPreview scale-aware selection#898
Shreyag02 wants to merge 6 commits into
feat/calendar-preview-rangepickerfrom
feat/calendar-preview-scale

Conversation

@Shreyag02

@Shreyag02 Shreyag02 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 5 of 7 in the RFC 005 stack, on top of #897.

Selection at scales coarser than a day. This is the surface that forced the value contract — a Date cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value carries its own scale.

interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale }
In scales, trailingValue, 10 parts, and docs
Not in selection='multiple', presets, time-of-day, any pre-composed export
Deleted Nothing

Changes

Part Notes
.Body The popup body — label, input, switcher, panel. Input sits above the switcher
.Scales / .Scale Apsara Tabs. Renders nothing at one scale
.Panel Mounts all five views; each gates on the active scale itself
.Months / .Quarters / .HalfYears / .Years Year-grouped lists at 3 / 4 / 2 / 1 columns, 320px, one scroll
.Label / .Separator Field label, and the rule below the switcher
  • All period maths comes from lib/scale.tsperiodOf, anchorOf, convertScale, isAvailable. Zero date-fns imports outside the adapter.
  • A scale switch drafts, it does not emit. Moves the view, sets a draft; a cell click or Enter commits; Escape drops it and restores the scale.
  • .Input parses every offered scale and moves the scale to match. "Q4" in a day-only field is a typo, not a quarter.
  • trailingValue changes the value, not the formatting — a start field emits the period's first day, an end field its last.
  • A start/end pair is two independent roots, each with its own scales and trailingValue. "1 Aug 2026 → Q3 2026" is not expressible as one range value.
  • Docs: six demo tabs, the API entries, and eleven new slots.

Storage vs display

Two layers, easy to conflate:

Format Why
StoredScaleValue.date 2026-08-15 Lexicographic order is chronological order, so bounds compare with < and > and no parsing. Also dodges new Date('2026-08-01') rendering as 31 July at negative offsets — the RFC review's reason for it
DisplayedformatValue 15 Aug 2026 What every trigger, input and annotation renders

No YYYY-MM-DD reaches a screen. details.toDate() hands back a Date.

Availability depends on which end you are

The reason isAvailable takes trailing. The RFC's table, bounded at 15 July 2026:

Period Start emits End emits Start End
H1 2026 1 Jan 30 Jun disabled disabled
July 2026 1 Jul 31 Jul disabled available
Q3 2026 1 Jul 30 Sep disabled available

Every period starts before the bound. Only the produced date separates them.

Technical Details

Decision Reason
.Days gates on the day scale It becomes a sibling of the four period views, so .Panel mounts all five and .Quarters can stand alone. At scales='day' the scale is always 'day', so an inline calendar is unaffected
Escape restores the scale, not just the draft A day value otherwise still reads "Q3 2026" — the input reformats at the drafted scale
Scale switching anchors on month, not today A consumer opening on 2030, or a user who navigated there, was thrown back to this year. month already falls back to today
Period list scrolls its own container when the view activates A mount effect fired while the view was hidden and never fired again; scrollIntoView would have dragged the popover with it
period derives from the committed value's scale It read the root's scale state — the scale on screen. Typing "Q4 2026" committed a quarter but reported a single day
Four views, one file Same scroller at four column counts. Four files sharing a helper would need a fifth, which the rules forbid. Same shape as grid.tsx holding .Grid / .Day / .Weekday

Found in a real browser, not by tests

jsdom has no layout: scrollTop is always 0 and getBoundingClientRect always zeroes. Two bugs hid there.

Bug jsdom Real browser
Period list never scrolled to the active year passed scrollTop: 0, 2026 group 540px down a 320px viewport. Every switch landed on 2016; clicking "Q3" committed Q3 2016
After the fix scrollTop: 540, visible: true, commits 2026-07-01, trigger reads Q3 2026

That is the fourth bug this stack's browser probes have caught that the jsdom suite could not — three focus/dismissal, one layout. Worth extending the RFC's real-browser exit criterion beyond phase 2 to anything touching open state or scroll position.

Review notes

# Item State
1 scales discriminator (RFC Open Item 1) Decided as below — still has a wart worth a second opinion
2 .Picker's name (RFC Open Item 2) Settled: .Body. Slot is calendar-preview-body
3 Default day format Settled: DD MMM YYYY — matches the frames and the shipped DatePicker. The RFC's DD/MM/YYYY is superseded and needs updating

On the day formatformatDayLabel was day-first so a rendered value could be typed straight back in, which only held because lib/parse.ts accepted exactly what it produced. Changing the format alone would have broken that, so the parser learned the rendered form: 15 Aug 2026 and 15 August 2026 now parse at day scale. Every previously accepted input still works.

On the discriminator — TypeScript cannot test an array's contents, so the arms discriminate on the shape of scales:

scales value
omitted, or the literal 'day' Date (or DateRangeValue) — unchanged
any other scale, or any array ScaleValue

The wart: scales={['day']} takes the scale-aware arm where scales='day' does not. An explicit second prop would avoid it, at the cost of a redundant API.

Test Plan

  • Manual testing completed
  • Build and type checking passes
Check Result
New scale-selection.test.tsx 36 passed
calendar-preview/ total 439 passed
Full package suite 3146 passed, 1 skipped (skip predates this stack)
biome check / tsc --noEmit clean
build:apsara / docs build both green

Covered — a scale switch fires no onValueChange until a pick; trailingValue flips the emitted date for all four period scales, month-end correct in a leap February (2028-02-29); the availability table asserted from both ends; .Scales renders nothing at one scale; each view renders alone with no day grid in the tree; the trigger annotation formats all five scales with no popover open; Escape drops the draft and restores the input; .Input moves the scale to match typed text and refuses a scale the root does not offer; the rendered day form round-trips back through the parser and 31 Feb 2026 is rejected; toDate() returns the produced period edge and period is right on both the clicked and typed paths; scale switching anchors on the view month, not today.

Real browser, trusted input — 14 cases across the day picker, range and scale surfaces: focus-to-open with no flicker, Escape, outside press, Enter and outside-click commits, range completion auto-closing and staying closed, scale switch emitting nothing, period commit auto-closing and staying closed, and the period list opening on the active year.

SQL Safety (if your PR touches *_repository.go or goqu.*)

Not applicable — TypeScript and CSS only. No Go files, no database access.

PR 5 of 7. `scales` and `trailingValue` on the root, and eight parts:
`.Picker`, `.Label`, `.Scales`, `.Scale`, `.Separator`, `.Panel`, and the
four period views.

This is the surface that forced the value contract. A `Date` cannot say
whether it means "August 2026" or "1 August 2026", so beyond day scale
the value is a `ScaleValue` — `{ date: 'YYYY-MM-DD', scale }` — and the
scale travels with it rather than with a prop.

Every date computation goes through `lib/scale.ts`: `periodOf`,
`anchorOf`, `convertScale` and `isAvailable`. Nothing here does period
maths, and no component imports date-fns.

Availability tests the date a period would PRODUCE, not the period, so
the same period answers differently at each end of a pair. With a bound
of 15 July 2026, Q3 2026 is disabled for a start field (emits 1 July)
and available for an end field (emits 30 September). That is the RFC's
table, and it is the fixture.

A scale switch moves the view and sets a draft; it emits nothing. A cell
click or Enter commits. Escape drops the draft AND restores the scale
the value carries — without that the input still reads "Q3 2026" for a
day value, which the test caught.

`.Days` becomes a sibling view that gates on the day scale, the way the
four period views do, so `.Panel` can mount all five and a consumer can
mount `.Quarters` alone. That is a behaviour change for `.Days` and is
why the day-only default matters: at `scales='day'` the scale is always
'day', so an inline calendar is unaffected.

The period lists are one scrolling column with year headings inside it,
and open scrolled to the active year — a twenty-year list otherwise
opens on 2016, which the tests found first.

Open Item 1, the `scales` discriminator: TypeScript cannot test an
array's contents, so the arms discriminate on the SHAPE of `scales`.
Omitted or the literal 'day' keeps `Date`; any other scale, or any
array, moves to `ScaleValue`. The wart is that `scales={['day']}` takes
the scale-aware arm where `scales='day'` does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
apsara Ready Ready Preview Sep 7, 2026 7:15am UTC

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 465cdc5f-3df1-4988-8e90-63060807790b

📥 Commits

Reviewing files that changed from the base of the PR and between 18e89f6 and 2998a58.

📒 Files selected for processing (24)
  • apps/www/src/content/docs/components/calendar-preview/demo.ts
  • apps/www/src/content/docs/components/calendar-preview/index.mdx
  • packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
  • packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts
  • packages/raystack/components/calendar-preview/__tests__/parse.test.ts
  • packages/raystack/components/calendar-preview/__tests__/picker.test.tsx
  • packages/raystack/components/calendar-preview/__tests__/range.test.tsx
  • packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-body.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-context.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-days.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-input.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-label.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-panel.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-periods.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-root.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-scales.tsx
  • packages/raystack/components/calendar-preview/calendar-preview-separator.tsx
  • packages/raystack/components/calendar-preview/calendar-preview.module.css
  • packages/raystack/components/calendar-preview/calendar-preview.tsx
  • packages/raystack/components/calendar-preview/date-adapter.ts
  • packages/raystack/components/calendar-preview/index.tsx
  • packages/raystack/components/calendar-preview/lib/parse.ts
  • packages/raystack/index.tsx

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/@raystack/apsara@898

commit: 2998a58

Open Item 2 in the RFC, settled. `.Picker` overloaded the old `DatePicker`
vocabulary for what is just the popup body, and `.Field` would have
collided with Apsara's `Field`.

Renames the part, its props type, its display name and its `data-slot`.
The slot moves from `calendar-preview-picker` to `calendar-preview-body`,
which is semver-covered surface — it has never shipped, so this costs
nobody, but it is the last chance to make it free.

While here: the eight parts added in the previous commit were registered
on the root but their props types were never exported. They are now, from
both barrels, so a consumer can type a wrapper around any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Settles the last open item. The RFC set the default day format to
DD/MM/YYYY; the frames and the shipped `DatePicker`'s own `dateFormat`
both render `15 Aug 2026`. Going with the frames.

`formatDayLabel` was day-first for a stated reason — a rendered value
could be typed straight back into the field, because `lib/parse.ts`
accepted exactly what it produced. Changing the format alone would have
broken that: `parseScaleInput` had no pattern for a day with a month
name, so selecting all and retyping `15 Aug 2026` verbatim came back
unparseable.

So the parser learns the form the formatter renders. `15 Aug 2026` and
`15 August 2026` now parse at day scale, and `31 Feb 2026` is still
rejected, because `dayKeyFromParts` validates against the real calendar
rather than rolling forward. Every input form that worked before still
works — the slashed and ISO shapes are untouched, they are simply no
longer what gets rendered.

The multi-scale placeholder advertises the new form too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`toDate()` was already right for the scale arm — `selectPeriod` passes
the produced date as the occasion, so it hands back the period edge that
`trailingValue` chose, as a method rather than a field.

`period` was not. It was computed against the root's current `scale`
state, which is the scale on SCREEN, not the one being committed. On a
click those agree, because switching the view is what put the cells
there. On a typed commit they do not: "Q4 2026" typed while the view is
still on days committed a quarter but reported a single day as its
period.

It now derives the scale from the value being emitted, so the two cannot
drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR 5 shipped ten parts with no docs, so the scale surface was invisible
on the docs site — which is where it was noticed.

Adds the API entries for `.Body`, `.Scales`, `.Scale`, `.Panel`, the
four period views, `.Label` and `.Separator`, the eleven slots they
render, and a section covering the pieces that are not guessable from
the props: that the value carries its own scale, that switching drafts
rather than emits, what `trailingValue` does to the value, and the
availability table that falls out of it.

Two things the section has to say out loud, because both have already
caused confusion: `ScaleValue.date` is stored as `YYYY-MM-DD` and is
never what renders — `formatValue` puts `DD MMM YYYY` on screen and
`toDate()` hands back a `Date`; and a start/end pair is two independent
roots, not `selection='range'`, because the two ends can hold different
scales.

The first demo tab is the inline body rather than the popover form. The
popover renders as the words "Add start date" until you click it, which
is exactly why the preview looked missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list claimed to open on the active year and never did — a real
browser showed `scrollTop: 0` with the 2026 group 540px down a 320px
viewport. Every scale switch landed the user twenty years early, on
2016, and clicking what looked like "Q3" committed Q3 2016.

Two causes, both invisible to jsdom.

The effect ran on mount, but `.Panel` mounts all five views at once and
a view still runs its hooks while it returns null. So the effect fired
with an empty ref, and a mount effect never fires again when the view
later becomes visible. It now runs when the view becomes active.

`scrollIntoView` was also the wrong instrument: it walks every
scrollable ancestor, so it would move the popover along with the list.
Scrolling the container directly touches nothing else.

Separately, and found by the same probe: `switchScale` and the period
list both anchored on `today` rather than on `month`. A consumer opening
on 2030, or a user who navigated there in the day grid, was thrown back
to this year by switching scale. Both now follow the month on screen —
which already falls back to today when nothing else set it.

jsdom cannot see any of this: it has no layout, so `scrollTop` is always
0 and `getBoundingClientRect` is always zeroes. The tests cover the
anchor, which is observable; the scroll is verified in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant