@nepdatejs/react 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -2
  3. package/dist/date-text-BX3Uag0O.cjs +84 -0
  4. package/dist/date-text-BX3Uag0O.cjs.map +1 -0
  5. package/dist/date-text-DLwhBmYj.js +61 -0
  6. package/dist/date-text-DLwhBmYj.js.map +1 -0
  7. package/dist/date-text-Dipetpbp.d.cts +37 -0
  8. package/dist/date-text-Dipetpbp.d.ts +37 -0
  9. package/dist/headless-AEI6dxTJ.cjs +619 -0
  10. package/dist/headless-AEI6dxTJ.cjs.map +1 -0
  11. package/dist/headless-CVj0DhD4.js +560 -0
  12. package/dist/headless-CVj0DhD4.js.map +1 -0
  13. package/dist/headless-ChE3zuYj.d.ts +195 -0
  14. package/dist/headless-CrIIPnOF.d.cts +195 -0
  15. package/dist/headless.cjs +11 -0
  16. package/dist/headless.d.cts +2 -0
  17. package/dist/headless.d.ts +2 -0
  18. package/dist/headless.js +2 -0
  19. package/dist/index.cjs +915 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.cts +245 -0
  22. package/dist/index.d.ts +245 -0
  23. package/dist/index.js +895 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/server.cjs +6 -0
  26. package/dist/server.d.cts +2 -0
  27. package/dist/server.d.ts +2 -0
  28. package/dist/server.js +1 -0
  29. package/package.json +110 -3
  30. package/src/bindings.ts +247 -0
  31. package/src/calendar-body.tsx +133 -0
  32. package/src/calendar.tsx +34 -0
  33. package/src/date-picker.tsx +343 -0
  34. package/src/date-range-picker.tsx +225 -0
  35. package/src/date-text.tsx +110 -0
  36. package/src/date-time-picker.tsx +289 -0
  37. package/src/headless.ts +21 -0
  38. package/src/hooks.ts +476 -0
  39. package/src/icons.tsx +35 -0
  40. package/src/index.ts +21 -0
  41. package/src/multi-date-picker.tsx +245 -0
  42. package/src/page-locale.ts +23 -0
  43. package/src/panel.ts +67 -0
  44. package/src/presets.tsx +44 -0
  45. package/src/server.ts +8 -0
  46. package/src/time-columns.tsx +60 -0
  47. package/src/use-store.ts +196 -0
@@ -0,0 +1,245 @@
1
+ import { useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
2
+ import {
3
+ getLocale,
4
+ type CalendarDate,
5
+ type CalendarSystem,
6
+ type LocaleCode,
7
+ type Weekday,
8
+ } from '@nepdatejs/core';
9
+ import type { EventMarker, HolidayInput, HolidayLookup } from '@nepdatejs/calendar';
10
+ import type { DateDetail } from '@nepdatejs/datepicker';
11
+ import { restoreFocus, type PanelLayout } from '@nepdatejs/dom';
12
+ import { CalendarBody } from './calendar-body';
13
+ import { useMultiDatePicker } from './hooks';
14
+ import { Icon } from './icons';
15
+ import { usePageLocale } from './page-locale';
16
+ import { usePanel } from './panel';
17
+ import { PANEL_WIDTH } from './presets';
18
+
19
+ export interface MultiDatePickerProps {
20
+ /** AD "YYYY-MM-DD" days. */
21
+ value?: readonly string[] | undefined;
22
+ defaultValue?: readonly string[] | undefined;
23
+ onChange?:
24
+ ((value: readonly string[], dates: readonly DateDetail[]) => void) | undefined;
25
+ calendar?: CalendarSystem | undefined;
26
+ defaultCalendar?: CalendarSystem | undefined;
27
+ onCalendarChange?: ((calendar: CalendarSystem) => void) | undefined;
28
+ /** Earliest and latest allowed days, as AD "YYYY-MM-DD". */
29
+ min?: string | undefined;
30
+ max?: string | undefined;
31
+ /** The most days that may be chosen. */
32
+ maxDates?: number | undefined;
33
+ isDateDisabled?: ((date: CalendarDate) => boolean) | undefined;
34
+ holidays?: readonly HolidayInput[] | HolidayLookup | undefined;
35
+ events?: ((date: CalendarDate) => readonly EventMarker[]) | undefined;
36
+ locale?: LocaleCode | undefined;
37
+ weekStartsOn?: Weekday | undefined;
38
+ weekendDays?: readonly Weekday[] | undefined;
39
+ showOtherCalendar?: boolean | undefined;
40
+ /** Close the panel after each click. Default false. */
41
+ closeOnSelect?: boolean | undefined;
42
+ /** Add the dashes of YYYY-MM-DD while the user types digits. Default true. */
43
+ mask?: boolean | undefined;
44
+ /** Show the calendar in the page, always open, instead of in a popover. */
45
+ inline?: boolean | undefined;
46
+ /** 'sheet' opens a bottom sheet; 'popover' sits beside the field; 'auto' (default) uses the sheet below 480px. */
47
+ panelLayout?: PanelLayout | undefined;
48
+ /** Submit each day under this name, one hidden input per day. */
49
+ name?: string | undefined;
50
+ id?: string | undefined;
51
+ placeholder?: string | undefined;
52
+ required?: boolean | undefined;
53
+ disabled?: boolean | undefined;
54
+ clearable?: boolean | undefined;
55
+ today?: CalendarDate | undefined;
56
+ theme?: 'light' | 'dark' | 'auto' | undefined;
57
+ className?: string | undefined;
58
+ 'aria-label'?: string | undefined;
59
+ }
60
+
61
+ /**
62
+ * Several separate BS/AD days in one field (PICK-001): click days in the
63
+ * panel to add or remove them, or type them separated by commas. The value is
64
+ * the sorted AD days as "YYYY-MM-DD" strings.
65
+ */
66
+ export function MultiDatePicker({
67
+ name,
68
+ placeholder,
69
+ required,
70
+ disabled = false,
71
+ clearable = true,
72
+ panelLayout,
73
+ theme,
74
+ className,
75
+ 'aria-label': ariaLabel,
76
+ ...options
77
+ }: MultiDatePickerProps): ReactElement {
78
+ const locale = usePageLocale(options.locale);
79
+ const picker = useMultiDatePicker({ ...options, locale });
80
+ const { state, store, calendar } = picker;
81
+ const labels = getLocale(locale);
82
+ const field = useRef<HTMLDivElement | null>(null);
83
+ const box = useRef<HTMLDivElement | null>(null);
84
+ const panel = useRef<HTMLDivElement | null>(null);
85
+ const input = useRef<HTMLInputElement | null>(null);
86
+
87
+ usePanel(
88
+ state.open && !state.inline,
89
+ { anchor: box, panel, field, returnFocus: input },
90
+ () => {
91
+ store.actions.close();
92
+ },
93
+ calendar.focusGrid,
94
+ PANEL_WIDTH,
95
+ panelLayout,
96
+ );
97
+
98
+ // A form reset puts the field back to its default value.
99
+ useEffect(() => {
100
+ const form = input.current?.form;
101
+ if (!form) {
102
+ return undefined;
103
+ }
104
+ const reset = (): void => {
105
+ store.actions.reset();
106
+ };
107
+ form.addEventListener('reset', reset);
108
+
109
+ return () => {
110
+ form.removeEventListener('reset', reset);
111
+ };
112
+ }, [store]);
113
+
114
+ useEffect(() => {
115
+ input.current?.setCustomValidity(state.error?.message ?? '');
116
+ }, [state.error]);
117
+
118
+ return (
119
+ <div
120
+ ref={field}
121
+ className={className ? `ndp-field ${className}` : 'ndp-field'}
122
+ data-ndp-theme={theme}
123
+ >
124
+ <div
125
+ ref={box}
126
+ className="ndp-input"
127
+ data-invalid={state.error !== null || undefined}
128
+ data-disabled={disabled || undefined}
129
+ >
130
+ <input
131
+ ref={input}
132
+ className="ndp-input__control"
133
+ {...picker.getInputProps()}
134
+ aria-label={ariaLabel}
135
+ placeholder={placeholder ?? state.placeholder}
136
+ required={required}
137
+ disabled={disabled}
138
+ />
139
+ <span className="ndp-input__actions">
140
+ {clearable && state.text !== '' && !disabled && (
141
+ <button
142
+ className="ndp-icon-button"
143
+ {...picker.getClearProps()}
144
+ onClick={() => {
145
+ store.actions.clear();
146
+ input.current?.focus();
147
+ }}
148
+ >
149
+ <Icon name="close" />
150
+ </button>
151
+ )}
152
+ {!state.inline && (
153
+ <button
154
+ className="ndp-icon-button"
155
+ disabled={disabled}
156
+ {...picker.getToggleProps()}
157
+ >
158
+ <Icon name="calendar" />
159
+ </button>
160
+ )}
161
+ </span>
162
+ </div>
163
+ {state.error && (
164
+ <p id={picker.ids.hint} className="ndp-hint" data-error aria-live="polite">
165
+ {state.error.message}
166
+ </p>
167
+ )}
168
+ {name !== undefined &&
169
+ state.value.map((day) => (
170
+ <input key={day} type="hidden" name={name} value={day} disabled={disabled} />
171
+ ))}
172
+ {state.open && (
173
+ <div
174
+ ref={panel}
175
+ className="ndp-panel"
176
+ popover={state.inline ? undefined : 'manual'}
177
+ data-layout={state.inline ? 'inline' : undefined}
178
+ style={
179
+ state.inline
180
+ ? ({ '--ndp-panel-width': `${String(PANEL_WIDTH)}px` } as CSSProperties)
181
+ : undefined
182
+ }
183
+ data-ndp-theme={theme}
184
+ {...picker.getPanelProps()}
185
+ aria-label={
186
+ ariaLabel
187
+ ? `${ariaLabel}: ${labels.labels.calendar}`
188
+ : labels.labels.chooseDate
189
+ }
190
+ >
191
+ <CalendarBody
192
+ bindings={{
193
+ ...calendar,
194
+ getDayProps: (cell) => ({
195
+ ...calendar.getDayProps(cell),
196
+ onClick: () => {
197
+ if (
198
+ !cell.isDisabled &&
199
+ store.actions.select(cell.date) &&
200
+ !store.getState().open
201
+ ) {
202
+ restoreFocus(input.current);
203
+ }
204
+ },
205
+ }),
206
+ }}
207
+ />
208
+ <div className="ndp-panel__footer">
209
+ {state.canSwitchCalendar ? (
210
+ <div
211
+ className="ndp-segment"
212
+ role="group"
213
+ aria-label={labels.labels.calendar}
214
+ >
215
+ {(['BS', 'AD'] as const).map((option) => (
216
+ <button
217
+ key={option}
218
+ className="ndp-segment__item"
219
+ {...picker.getCalendarOptionProps(option)}
220
+ >
221
+ {labels.calendarNames[option]}
222
+ </button>
223
+ ))}
224
+ </div>
225
+ ) : (
226
+ <span />
227
+ )}
228
+ {!state.inline && (
229
+ <button
230
+ type="button"
231
+ className="ndp-button"
232
+ onClick={() => {
233
+ store.actions.close();
234
+ restoreFocus(input.current);
235
+ }}
236
+ >
237
+ {labels.labels.done}
238
+ </button>
239
+ )}
240
+ </div>
241
+ </div>
242
+ )}
243
+ </div>
244
+ );
245
+ }
@@ -0,0 +1,23 @@
1
+ import { useSyncExternalStore } from 'react';
2
+ import { getConfig, type LocaleCode } from '@nepdatejs/core';
3
+
4
+ const neverChanges = (): (() => void) => () => undefined;
5
+
6
+ /** Used on the server and while hydrating, so both render the same text. */
7
+ function defaultLocale(): LocaleCode {
8
+ return getConfig().defaultLocale;
9
+ }
10
+
11
+ /** Nepali names and digits when the page language is Nepali. Browser only. */
12
+ function pageLocale(): LocaleCode {
13
+ return document.documentElement.lang.toLowerCase().startsWith('ne')
14
+ ? 'ne'
15
+ : defaultLocale();
16
+ }
17
+
18
+ /** The given locale, or else the page's language. */
19
+ export function usePageLocale(given: LocaleCode | undefined): LocaleCode {
20
+ const page = useSyncExternalStore(neverChanges, pageLocale, defaultLocale);
21
+
22
+ return given ?? page;
23
+ }
package/src/panel.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { useEffect, useRef, type RefObject } from 'react';
2
+ import {
3
+ onBackdropPointer,
4
+ onEscape,
5
+ onOutsidePointer,
6
+ restoreFocus,
7
+ trackPosition,
8
+ type PanelLayout,
9
+ } from '@nepdatejs/dom';
10
+ import { useIsomorphicLayoutEffect } from './use-store';
11
+
12
+ /**
13
+ * While `open`: show the panel in the top layer beside the anchor, close on an
14
+ * outside pointer, a tap on a bottom sheet's backdrop, or Escape (returning focus to `returnFocus`), and move focus
15
+ * into the calendar.
16
+ */
17
+ export function usePanel(
18
+ open: boolean,
19
+ refs: {
20
+ anchor: RefObject<HTMLElement | null>;
21
+ panel: RefObject<HTMLDivElement | null>;
22
+ field: RefObject<HTMLElement | null>;
23
+ returnFocus: RefObject<HTMLElement | null>;
24
+ },
25
+ close: () => void,
26
+ focusGrid: () => void,
27
+ width = 320,
28
+ layout: PanelLayout = 'auto',
29
+ ): void {
30
+ const closeRef = useRef(close);
31
+ closeRef.current = close;
32
+
33
+ useIsomorphicLayoutEffect(() => {
34
+ const anchor = refs.anchor.current;
35
+ const panel = refs.panel.current;
36
+ if (!open || !anchor || !panel) {
37
+ return undefined;
38
+ }
39
+ const stopTracking = trackPosition(anchor, panel, { width, layout });
40
+ const stopOutside = onOutsidePointer(
41
+ () => [refs.field.current, refs.panel.current],
42
+ () => {
43
+ closeRef.current();
44
+ },
45
+ );
46
+ const stopBackdrop = onBackdropPointer(panel, () => {
47
+ closeRef.current();
48
+ });
49
+ const stopEscape = onEscape(() => {
50
+ closeRef.current();
51
+ restoreFocus(refs.returnFocus.current);
52
+ });
53
+
54
+ return () => {
55
+ stopTracking();
56
+ stopOutside();
57
+ stopBackdrop();
58
+ stopEscape();
59
+ };
60
+ }, [open, width, layout]);
61
+
62
+ useEffect(() => {
63
+ if (open) {
64
+ focusGrid();
65
+ }
66
+ }, [open]);
67
+ }
@@ -0,0 +1,44 @@
1
+ import type { ReactElement, ReactNode } from 'react';
2
+ import type { PresetState } from '@nepdatejs/datepicker';
3
+
4
+ /** Panel widths: calendar alone, plus presets column, plus time columns. */
5
+ export const PANEL_WIDTH = 320;
6
+ export const PANEL_WIDTH_WITH_PRESETS = 492;
7
+ export const TIME_COLUMNS_WIDTH = 136;
8
+
9
+ /** The panel body: the shortcut list (when there are presets), then the calendar and anything else. */
10
+ export function PanelBody({
11
+ presets,
12
+ label,
13
+ onPreset,
14
+ children,
15
+ }: {
16
+ presets: readonly PresetState[];
17
+ label: string;
18
+ onPreset: (id: string) => void;
19
+ children: ReactNode;
20
+ }): ReactElement {
21
+ return (
22
+ <div className="ndp-panel__body">
23
+ {presets.length > 0 && (
24
+ <div className="ndp-presets" role="group" aria-label={label}>
25
+ {presets.map((preset) => (
26
+ <button
27
+ key={preset.id}
28
+ type="button"
29
+ className="ndp-preset"
30
+ aria-pressed={preset.isActive}
31
+ disabled={preset.isDisabled}
32
+ onClick={() => {
33
+ onPreset(preset.id);
34
+ }}
35
+ >
36
+ {preset.label}
37
+ </button>
38
+ ))}
39
+ </div>
40
+ )}
41
+ {children}
42
+ </div>
43
+ );
44
+ }
package/src/server.ts ADDED
@@ -0,0 +1,8 @@
1
+ /** @nepdatejs/react/server: components without hooks, safe in React Server Components. */
2
+ export {
3
+ bothDates,
4
+ DateText,
5
+ shortDate,
6
+ ShortDate,
7
+ type DateTextProps,
8
+ } from './date-text';
@@ -0,0 +1,60 @@
1
+ import { useEffect, useRef, type ReactElement } from 'react';
2
+ import { getLocale, type LocaleCode } from '@nepdatejs/core';
3
+ import type { TimeCell } from '@nepdatejs/datepicker';
4
+
5
+ /** Hour and minute columns (Figma "Time Item"); the selected items scroll into view. */
6
+ export function TimeColumns({
7
+ hours,
8
+ minutes,
9
+ locale,
10
+ onHour,
11
+ onMinute,
12
+ }: {
13
+ hours: readonly TimeCell[];
14
+ minutes: readonly TimeCell[];
15
+ locale?: LocaleCode | undefined;
16
+ onHour: (hour: number) => void;
17
+ onMinute: (minute: number) => void;
18
+ }): ReactElement {
19
+ const root = useRef<HTMLDivElement | null>(null);
20
+ const labels = getLocale(locale).labels;
21
+
22
+ useEffect(() => {
23
+ for (const item of root.current?.querySelectorAll<HTMLElement>(
24
+ '[aria-pressed="true"]',
25
+ ) ?? []) {
26
+ if (typeof item.scrollIntoView === 'function') {
27
+ item.scrollIntoView({ block: 'nearest' });
28
+ }
29
+ }
30
+ }, []);
31
+
32
+ const column = (
33
+ label: string,
34
+ cells: readonly TimeCell[],
35
+ pick: (value: number) => void,
36
+ ): ReactElement => (
37
+ <div className="ndp-time__column" role="group" aria-label={label}>
38
+ {cells.map((cell) => (
39
+ <button
40
+ key={cell.value}
41
+ type="button"
42
+ className="ndp-time-item"
43
+ aria-pressed={cell.isSelected}
44
+ onClick={() => {
45
+ pick(cell.value);
46
+ }}
47
+ >
48
+ {cell.label}
49
+ </button>
50
+ ))}
51
+ </div>
52
+ );
53
+
54
+ return (
55
+ <div ref={root} className="ndp-time" role="group" aria-label={labels.time}>
56
+ {column(labels.hour, hours, onHour)}
57
+ {column(labels.minute, minutes, onMinute)}
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,196 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useRef,
5
+ useState,
6
+ useSyncExternalStore,
7
+ } from 'react';
8
+
9
+ interface StoreLike<O, S> {
10
+ getState(): S;
11
+ subscribe(listener: () => void): () => void;
12
+ setOptions(changes: Partial<O>): void;
13
+ readonly actions?: object;
14
+ }
15
+
16
+ /** Function options whose answers decide how the calendar's days look. */
17
+ const DAY_FUNCTIONS = ['isDateDisabled', 'holidays', 'events'] as const;
18
+
19
+ interface DayAnswers {
20
+ /** The days on screen; moving to another month isn't a change of answers. */
21
+ readonly days: string;
22
+ readonly answers: string;
23
+ }
24
+
25
+ /** useLayoutEffect in the browser, useEffect on the server (no warning). */
26
+ export const useIsomorphicLayoutEffect: typeof useEffect =
27
+ typeof window === 'undefined' ? useEffect : useLayoutEffect;
28
+
29
+ type Fn = (...args: never[]) => unknown;
30
+
31
+ /**
32
+ * Create a store once and keep its options in step with props, without
33
+ * loops: functions are wrapped once so their identity never changes, and data
34
+ * options are compared by value, so inline objects and arrays are fine.
35
+ */
36
+ export function useStore<O extends object, S, T extends StoreLike<O, S>>(
37
+ create: (options: O) => T,
38
+ options: O,
39
+ ): { store: T; state: S } {
40
+ const latest = useRef(options);
41
+ latest.current = options;
42
+ const wrappers = useRef(new Map<string, Fn>());
43
+
44
+ const prepare = (input: O): O => {
45
+ const out: Record<string, unknown> = {};
46
+ for (const [key, value] of Object.entries(input)) {
47
+ if (typeof value === 'function') {
48
+ let wrapper = wrappers.current.get(key);
49
+ if (!wrapper) {
50
+ wrapper = (...args: never[]) =>
51
+ ((latest.current as Record<string, unknown>)[key] as Fn | undefined)?.(
52
+ ...args,
53
+ );
54
+ wrappers.current.set(key, wrapper);
55
+ }
56
+ out[key] = wrapper;
57
+ } else {
58
+ out[key] = value;
59
+ }
60
+ }
61
+
62
+ return out as O;
63
+ };
64
+
65
+ const [store] = useState(() => create(prepare(options)));
66
+ const previous = useRef<O>(prepare(options));
67
+ const dayAnswers = useRef<DayAnswers | null>(null);
68
+ const storeValue = useRef(valueOf(store.getState()));
69
+
70
+ useIsomorphicLayoutEffect(() => {
71
+ const next = prepare(options) as Record<string, unknown>;
72
+ const before = previous.current as Record<string, unknown>;
73
+ const changes: Record<string, unknown> = {};
74
+ for (const key of new Set([...Object.keys(before), ...Object.keys(next)])) {
75
+ if (!deepEqual(before[key], next[key])) {
76
+ changes[key] = next[key];
77
+ }
78
+ }
79
+ previous.current = next as O;
80
+
81
+ // An inline function is wrapped once, so it never looks changed above. Ask it
82
+ // about the days on screen instead, and redraw only if an answer is different.
83
+ const current = askDayFunctions(options, store.getState());
84
+ const asked = dayAnswers.current;
85
+ dayAnswers.current = current;
86
+ if (asked?.days === current?.days && asked?.answers !== current?.answers) {
87
+ for (const key of DAY_FUNCTIONS) {
88
+ if (key in next) {
89
+ changes[key] = next[key];
90
+ }
91
+ }
92
+ }
93
+ if (Object.keys(changes).length > 0) {
94
+ store.setOptions(changes as Partial<O>);
95
+ }
96
+
97
+ // A controlled picker shows what its parent holds. If the person entered a
98
+ // date and the parent kept its value, put that value back.
99
+ const value = valueOf(store.getState());
100
+ const changedByUser = !deepEqual(value, storeValue.current) && !('value' in changes);
101
+ storeValue.current = value;
102
+ const held = next['value'];
103
+ if (
104
+ changedByUser &&
105
+ held !== undefined &&
106
+ isChosen(value) &&
107
+ !deepEqual(value, held)
108
+ ) {
109
+ (store.actions as { setValue?: (value: unknown) => void } | undefined)?.setValue?.(
110
+ held,
111
+ );
112
+ storeValue.current = valueOf(store.getState());
113
+ }
114
+ });
115
+
116
+ const state = useSyncExternalStore(
117
+ (listener) => store.subscribe(listener),
118
+ () => store.getState(),
119
+ () => store.getState(),
120
+ );
121
+
122
+ return { store, state };
123
+ }
124
+
125
+ function valueOf(state: unknown): unknown {
126
+ return (state as { value?: unknown }).value;
127
+ }
128
+
129
+ /** A finished value: not empty, half typed, or a range with one end still to pick. */
130
+ function isChosen(value: unknown): boolean {
131
+ if (Array.isArray(value)) {
132
+ return value.length > 0;
133
+ }
134
+ if (typeof value === 'object' && value !== null) {
135
+ return Object.values(value).every((part) => part !== '');
136
+ }
137
+
138
+ return value !== '' && value !== undefined;
139
+ }
140
+
141
+ /** What the day functions say about the days on screen, or null when there are none. */
142
+ function askDayFunctions(options: object, state: unknown): DayAnswers | null {
143
+ const given = DAY_FUNCTIONS.flatMap((key) => {
144
+ const option = (options as Record<string, unknown>)[key];
145
+
146
+ return typeof option === 'function' ? [option as (date: unknown) => unknown] : [];
147
+ });
148
+ if (given.length === 0) {
149
+ return null;
150
+ }
151
+ const { weeks, calendarState } = state as {
152
+ weeks?: readonly (readonly ({ date: unknown } | null)[])[];
153
+ calendarState?: { weeks: readonly (readonly ({ date: unknown } | null)[])[] };
154
+ };
155
+ const dates = (weeks ?? calendarState?.weeks ?? [])
156
+ .flat()
157
+ .flatMap((cell) => (cell ? [cell.date] : []));
158
+
159
+ return {
160
+ days: JSON.stringify(dates),
161
+ answers: JSON.stringify(dates.map((date) => given.map((ask) => ask(date)))),
162
+ };
163
+ }
164
+
165
+ /** Structural equality for plain data (dates, ranges, lists); identity for everything else. */
166
+ export function deepEqual(a: unknown, b: unknown): boolean {
167
+ if (Object.is(a, b)) {
168
+ return true;
169
+ }
170
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
171
+ return false;
172
+ }
173
+ if (Array.isArray(a) || Array.isArray(b)) {
174
+ return (
175
+ Array.isArray(a) &&
176
+ Array.isArray(b) &&
177
+ a.length === b.length &&
178
+ a.every((item, index) => deepEqual(item, b[index]))
179
+ );
180
+ }
181
+ if (
182
+ Object.getPrototypeOf(a) !== Object.prototype ||
183
+ Object.getPrototypeOf(b) !== Object.prototype
184
+ ) {
185
+ return false;
186
+ }
187
+ const keysA = Object.keys(a);
188
+ const keysB = Object.keys(b);
189
+
190
+ return (
191
+ keysA.length === keysB.length &&
192
+ keysA.every((key) =>
193
+ deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
194
+ )
195
+ );
196
+ }