@tuturuuu/ui 0.37.0 → 0.37.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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +6 -4
  3. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  4. package/src/components/ui/date-time-picker-values.ts +205 -0
  5. package/src/components/ui/date-time-picker.tsx +70 -167
  6. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  7. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  8. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  9. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  10. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  11. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  12. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  13. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  14. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  15. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  16. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  17. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  18. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  19. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  20. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  21. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  22. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  23. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  24. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  25. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  26. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  27. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  28. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  29. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  30. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  31. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  32. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  33. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  34. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  35. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  36. package/src/hooks/calendar-event-lookup.ts +22 -8
  37. package/src/hooks/calendar-sync-range.test.ts +92 -0
  38. package/src/hooks/calendar-sync-range.ts +40 -0
  39. package/src/hooks/use-calendar-clock.ts +49 -0
  40. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  41. package/src/hooks/use-calendar-sync.tsx +42 -80
  42. package/src/hooks/use-calendar.tsx +3 -3
  43. package/src/lib/calendar-day.ts +67 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.37.1](https://github.com/tutur3u/platform/compare/ui-v0.37.0...ui-v0.37.1) (2026-10-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **calendar:** align calendar-day navigation and zoned query ranges ([e81a8be](https://github.com/tutur3u/platform/commit/e81a8be8edf2e46b2653bfcf30bc118d12984e88))
9
+ * **calendar:** align date navigation and zoned query ranges ([#5654](https://github.com/tutur3u/platform/issues/5654)) ([f40288c](https://github.com/tutur3u/platform/commit/f40288c7cc817f71ca30f6ee5331efbf726c4ada))
10
+ * **calendar:** format clock options without browser dates ([757709e](https://github.com/tutur3u/platform/commit/757709ecbf1070cfa3248ece54f9b9c66cd000c7))
11
+ * **calendar:** honor resolved timezone in event picker ([eae3a25](https://github.com/tutur3u/platform/commit/eae3a25ec443fbb1fe51acf1c642a6b44e6c80f8)) ([#5648](https://github.com/tutur3u/platform/issues/5648)) ([7ccc4b3](https://github.com/tutur3u/platform/commit/7ccc4b356411744f5b90e55c35c1e6f759981d42))
12
+ * **calendar:** preserve explicit time bounds and guard DST rollover ([56d746b](https://github.com/tutur3u/platform/commit/56d746b91e8c4d48c6c97ad88a32cab74f8457cc))
13
+ * **calendar:** respect timezone in picker bounds and previews ([47720c7](https://github.com/tutur3u/platform/commit/47720c72d09545cd86d3d3814d4fb83f53848890))
14
+ * **calendar:** type-check picker regression import ([60aef11](https://github.com/tutur3u/platform/commit/60aef114c7ad9b3b327befaee37798db1a98b9b6))
15
+ * **calendar:** use calendar timezone for picker bounds and previews ([#5653](https://github.com/tutur3u/platform/issues/5653)) ([ea912c0](https://github.com/tutur3u/platform/commit/ea912c0fd89bf54ca073ae9c8b219a3696db4d19))
16
+
3
17
  ## [0.37.0](https://github.com/tutur3u/platform/compare/ui-v0.36.1...ui-v0.37.0) (2026-09-29)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.37.0",
3
+ "version": "0.37.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -91,9 +91,9 @@
91
91
  "@tuturuuu/apis": "0.13.0",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.47.0",
94
+ "@tuturuuu/internal-api": "0.48.0",
95
95
  "@tuturuuu/supabase": "0.5.2",
96
- "@tuturuuu/utils": "0.33.0",
96
+ "@tuturuuu/utils": "0.33.2",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -154,7 +154,7 @@
154
154
  "@tanstack/react-table": "^9.2.4",
155
155
  "@testing-library/jest-dom": "^7.0.1",
156
156
  "@testing-library/react": "^16.3.3",
157
- "@tuturuuu/types": "0.33.0",
157
+ "@tuturuuu/types": "0.33.1",
158
158
  "@tuturuuu/typescript-config": "0.1.2",
159
159
  "@types/html2canvas": "^1.0.0",
160
160
  "@types/lodash": "^4.17.25",
@@ -226,6 +226,8 @@
226
226
  "./hooks/*": "./src/hooks/*.ts",
227
227
  "./hooks/time-blocking-provider": "./src/hooks/time-blocking-provider.tsx",
228
228
  "./hooks/use-at-bottom": "./src/hooks/use-at-bottom.tsx",
229
+ "./hooks/use-calendar-day-zone": "./src/hooks/use-calendar-day-zone.tsx",
230
+ "./lib/calendar-day": "./src/lib/calendar-day.ts",
229
231
  "./hooks/use-calendar-preferences": "./src/hooks/use-calendar-preferences.tsx",
230
232
  "./hooks/use-calendar-sync": "./src/hooks/use-calendar-sync.tsx",
231
233
  "./hooks/use-calendar": "./src/hooks/use-calendar.tsx",
@@ -0,0 +1,258 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react';
2
+ import { useState } from 'react';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { DateTimePicker } from './date-time-picker';
5
+ import {
6
+ createPickerTimeOptions,
7
+ filterPickerTimeOptions,
8
+ pickerCalendarBounds,
9
+ pickerCalendarDate,
10
+ pickerTimeValue,
11
+ pickerTimeWithMinimum,
12
+ pickerWallTime,
13
+ } from './date-time-picker-values';
14
+ import { eventEndPickerBounds } from './legacy/calendar/event-picker-bounds';
15
+
16
+ const zone = 'Asia/Ho_Chi_Minh';
17
+ const start = new Date('2026-10-01T03:00:00Z');
18
+ const end = new Date('2026-10-01T04:00:00Z');
19
+
20
+ function EndPicker() {
21
+ const [date, setDate] = useState<Date | undefined>(end);
22
+ return (
23
+ <>
24
+ <DateTimePicker
25
+ date={date}
26
+ setDate={setDate}
27
+ inline
28
+ {...eventEndPickerBounds(start.toISOString(), zone, false)}
29
+ preferences={{ timezone: zone, timeFormat: '24h' }}
30
+ />
31
+ <output aria-label="Stored end">{date?.toISOString()}</output>
32
+ </>
33
+ );
34
+ }
35
+
36
+ describe('calendar picker bounds with a different browser day', () => {
37
+ it('formats clock-only options independently of the current browser DST day', () => {
38
+ vi.useFakeTimers();
39
+ try {
40
+ vi.setSystemTime(new Date('2026-03-08T10:00:00Z'));
41
+ expect(
42
+ createPickerTimeOptions('24h').find((x) => x.value === '02:30')?.display
43
+ ).toBe('02:30');
44
+ const twelveHour = createPickerTimeOptions('12h');
45
+ expect(twelveHour.find((x) => x.value === '02:30')?.display).toBe(
46
+ '2:30 AM'
47
+ );
48
+ expect(twelveHour[0]?.display).toBe('12:00 AM');
49
+ expect(twelveHour.find((x) => x.value === '12:00')?.display).toBe(
50
+ '12:00 PM'
51
+ );
52
+ } finally {
53
+ vi.useRealTimers();
54
+ }
55
+ });
56
+
57
+ it('rejects nonexistent DST wall times and retains an unchanged later overlap instant', () => {
58
+ const gap = new Date('2026-03-08T06:30:00Z');
59
+ const ny = 'America/New_York';
60
+ expect(pickerWallTime(gap, 2, 30, ny)).toBeUndefined();
61
+ const options = filterPickerTimeOptions({
62
+ date: gap,
63
+ zone: ny,
64
+ pattern: 'HH:mm',
65
+ timeFormat: '24h',
66
+ options: createPickerTimeOptions('24h'),
67
+ });
68
+ expect(options.some((x) => x.value.startsWith('02:'))).toBe(false);
69
+ const overlap = new Date('2026-11-01T06:30:00Z');
70
+ expect(pickerWallTime(overlap, 1, 30, ny)?.toISOString()).toBe(
71
+ overlap.toISOString()
72
+ );
73
+ });
74
+
75
+ it('keeps a nonexistent manual time invalid without changing the stored instant', () => {
76
+ let saved = new Date('2026-03-08T06:30:00Z');
77
+ render(
78
+ <DateTimePicker
79
+ date={saved}
80
+ setDate={(next) => {
81
+ if (next) saved = next;
82
+ }}
83
+ inline
84
+ preferences={{ timezone: 'America/New_York' }}
85
+ />
86
+ );
87
+ fireEvent.click(
88
+ screen.getByRole('button', { name: 'Switch to manual time entry' })
89
+ );
90
+ const input = screen.getByRole('textbox', {
91
+ name: 'Enter time manually in HH:MM',
92
+ });
93
+ fireEvent.change(input, { target: { value: '02:30' } });
94
+ fireEvent.keyDown(input, { key: 'Enter' });
95
+ expect(input.getAttribute('aria-invalid')).toBe('true');
96
+ expect(saved.toISOString()).toBe('2026-03-08T06:30:00.000Z');
97
+ });
98
+ it('preserves a later overlap instant when its calendar day is reselected', () => {
99
+ let saved = new Date('2026-11-01T06:30:00Z');
100
+ render(
101
+ <DateTimePicker
102
+ date={saved}
103
+ setDate={(next) => {
104
+ if (next) saved = next;
105
+ }}
106
+ inline
107
+ preferences={{ timezone: 'America/New_York' }}
108
+ />
109
+ );
110
+ fireEvent.click(screen.getByRole('button', { name: /November 1st, 2026/ }));
111
+ expect(saved.toISOString()).toBe('2026-11-01T06:30:00.000Z');
112
+ });
113
+
114
+ it('does not normalize a selected date into a nonexistent DST wall time', () => {
115
+ let saved = new Date('2026-03-07T07:30:00Z');
116
+ render(
117
+ <DateTimePicker
118
+ date={saved}
119
+ setDate={(next) => {
120
+ if (next) saved = next;
121
+ }}
122
+ inline
123
+ preferences={{ timezone: 'America/New_York' }}
124
+ />
125
+ );
126
+ fireEvent.click(screen.getByRole('button', { name: /March 8th, 2026/ }));
127
+ expect(saved.toISOString()).toBe('2026-03-07T07:30:00.000Z');
128
+ });
129
+
130
+ it('shows October and accepts 11:30 as 04:30Z in the actual end picker', () => {
131
+ render(<EndPicker />);
132
+ expect(screen.getByText('October')).toBeTruthy();
133
+ fireEvent.click(
134
+ screen.getByRole('button', { name: 'Switch to manual time entry' })
135
+ );
136
+ const input = screen.getByRole('textbox', {
137
+ name: 'Enter time manually in HH:MM',
138
+ });
139
+ fireEvent.change(input, { target: { value: '11:30' } });
140
+ fireEvent.keyDown(input, { key: 'Enter' });
141
+ expect(screen.getByLabelText('Stored end').textContent).toBe(
142
+ '2026-10-01T04:30:00.000Z'
143
+ );
144
+ });
145
+
146
+ it('offers 10:15 onward rather than browser-local 20:15 onward', () => {
147
+ const options = filterPickerTimeOptions({
148
+ date: end,
149
+ minDate: start,
150
+ minTime: pickerTimeValue(start, zone),
151
+ zone,
152
+ pattern: 'HH:mm',
153
+ timeFormat: '24h',
154
+ options: createPickerTimeOptions('24h'),
155
+ });
156
+ expect(options[0]?.value).toBe('10:15');
157
+ expect(options.some((x) => x.value === '11:30')).toBe(true);
158
+ expect(options.some((x) => x.value === '09:45')).toBe(false);
159
+ expect(pickerCalendarDate(start, zone).getDate()).toBe(1);
160
+ expect(pickerCalendarBounds(start, undefined, zone)?.[0]).toEqual({
161
+ before: new Date(2026, 9, 1),
162
+ });
163
+ });
164
+
165
+ it('offers a full next calendar day across browser midnight', () => {
166
+ const options = filterPickerTimeOptions({
167
+ date: new Date('2026-10-01T17:30:00Z'),
168
+ minDate: start,
169
+ minTime: '10:00',
170
+ zone,
171
+ pattern: 'HH:mm',
172
+ timeFormat: '24h',
173
+ options: createPickerTimeOptions('24h'),
174
+ });
175
+ expect(options[0]?.value).toBe('00:00');
176
+ expect(options).toHaveLength(96);
177
+ });
178
+
179
+ it('validates a minimum rollover on its destination day across DST and month boundaries', () => {
180
+ const ny = 'America/New_York';
181
+ const beforeGap = new Date('2026-03-07T06:30:00Z');
182
+ const minimum = new Date('2026-03-07T17:00:00Z');
183
+ expect(
184
+ pickerTimeWithMinimum(beforeGap, 2, 30, ny, minimum)
185
+ ).toBeUndefined();
186
+ expect(
187
+ pickerTimeWithMinimum(beforeGap, 3, 30, ny, minimum)?.toISOString()
188
+ ).toBe('2026-03-08T07:30:00.000Z');
189
+ expect(
190
+ pickerTimeWithMinimum(
191
+ new Date('2026-10-31T05:30:00Z'),
192
+ 1,
193
+ 30,
194
+ ny,
195
+ new Date('2026-10-31T16:00:00Z')
196
+ )?.toISOString()
197
+ ).toBe('2026-11-01T05:30:00.000Z');
198
+ expect(
199
+ pickerTimeWithMinimum(
200
+ new Date('2026-12-31T05:30:00Z'),
201
+ 1,
202
+ 30,
203
+ ny,
204
+ new Date('2026-12-31T17:00:00Z')
205
+ )?.toISOString()
206
+ ).toBe('2027-01-01T06:30:00.000Z');
207
+ });
208
+ it('rejects a DST-gap option reached through the actual minimum rollover control', () => {
209
+ let saved = new Date('2026-03-07T06:30:00Z');
210
+ render(
211
+ <DateTimePicker
212
+ date={saved}
213
+ setDate={(next) => {
214
+ if (next) saved = next;
215
+ }}
216
+ minDate={new Date('2026-03-07T17:00:00Z')}
217
+ inline
218
+ preferences={{ timezone: 'America/New_York', timeFormat: '24h' }}
219
+ />
220
+ );
221
+ fireEvent.keyDown(
222
+ screen
223
+ .getAllByRole('combobox')
224
+ .find((element) => element.textContent?.includes('01:30'))!,
225
+ { key: 'Enter' }
226
+ );
227
+ fireEvent.click(screen.getByRole('option', { name: '02:30' }));
228
+ expect(saved.toISOString()).toBe('2026-03-07T06:30:00.000Z');
229
+ });
230
+ it('retains an explicit minTime that differs from minDate in a configured zone', () => {
231
+ const options = filterPickerTimeOptions({
232
+ date: end,
233
+ minDate: new Date('2026-09-30T17:00:00Z'),
234
+ minTime: '09:00',
235
+ zone,
236
+ pattern: 'HH:mm',
237
+ timeFormat: '24h',
238
+ options: createPickerTimeOptions('24h'),
239
+ });
240
+ expect(options[0]?.value).toBe('09:15');
241
+ expect(options.some((option) => option.value === '08:45')).toBe(false);
242
+ });
243
+ it('uses maxDate time and calendar boundary in the configured zone', () => {
244
+ const maxDate = new Date('2026-10-01T05:00:00Z');
245
+ const options = filterPickerTimeOptions({
246
+ date: end,
247
+ maxDate,
248
+ zone,
249
+ pattern: 'HH:mm',
250
+ timeFormat: '24h',
251
+ options: createPickerTimeOptions('24h'),
252
+ });
253
+ expect(options.at(-1)?.value).toBe('11:45');
254
+ expect(pickerCalendarBounds(undefined, maxDate, zone)?.[0]).toEqual({
255
+ after: new Date(2026, 9, 1),
256
+ });
257
+ });
258
+ });
@@ -0,0 +1,205 @@
1
+ import {
2
+ buildDateInTimezone,
3
+ formatInTimezone,
4
+ getDatePartsInTimezone,
5
+ } from '@tuturuuu/utils/task-date-timezone';
6
+ import { format } from 'date-fns';
7
+
8
+ /** A date-only carrier for the browser-local calendar widget, never an instant. */
9
+ export function pickerCalendarDate(date: Date, zone: string | null): Date {
10
+ const parts = zone
11
+ ? getDatePartsInTimezone(date, zone)
12
+ : {
13
+ year: date.getFullYear(),
14
+ month: date.getMonth() + 1,
15
+ day: date.getDate(),
16
+ };
17
+ return new Date(parts.year, parts.month - 1, parts.day);
18
+ }
19
+
20
+ export function pickerTimeValue(date: Date, zone: string | null): string {
21
+ return zone ? formatInTimezone(date, zone, 'HH:mm') : format(date, 'HH:mm');
22
+ }
23
+
24
+ /** Construct a requested calendar wall time without silently normalizing a DST gap. */
25
+ export function pickerWallTimeOnDay(
26
+ day: Date,
27
+ hour: number,
28
+ minute: number,
29
+ zone: string | null
30
+ ): Date | undefined {
31
+ const year = day.getFullYear();
32
+ const month = day.getMonth() + 1;
33
+ const date = day.getDate();
34
+ const next = zone
35
+ ? buildDateInTimezone(year, month, date, hour, minute, zone)
36
+ : new Date(year, month - 1, date, hour, minute);
37
+ const parts = zone
38
+ ? getDatePartsInTimezone(next, zone)
39
+ : {
40
+ year: next.getFullYear(),
41
+ month: next.getMonth() + 1,
42
+ day: next.getDate(),
43
+ hour: next.getHours(),
44
+ minute: next.getMinutes(),
45
+ };
46
+ return parts.year === year &&
47
+ parts.month === month &&
48
+ parts.day === date &&
49
+ parts.hour === hour &&
50
+ parts.minute === minute
51
+ ? next
52
+ : undefined;
53
+ }
54
+
55
+ /** Reject nonexistent wall times and preserve an unchanged ambiguous instant. */
56
+ export function pickerWallTime(
57
+ base: Date,
58
+ hour: number,
59
+ minute: number,
60
+ zone: string | null
61
+ ): Date | undefined {
62
+ if (!zone) {
63
+ if (base.getHours() === hour && base.getMinutes() === minute)
64
+ return new Date(base);
65
+ const next = new Date(base);
66
+ next.setHours(hour, minute, 0, 0);
67
+ return next.getHours() === hour && next.getMinutes() === minute
68
+ ? next
69
+ : undefined;
70
+ }
71
+ const parts = getDatePartsInTimezone(base, zone);
72
+ if (parts.hour === hour && parts.minute === minute) return new Date(base);
73
+ const next = buildDateInTimezone(
74
+ parts.year,
75
+ parts.month,
76
+ parts.day,
77
+ hour,
78
+ minute,
79
+ zone
80
+ );
81
+ const roundTrip = getDatePartsInTimezone(next, zone);
82
+ return roundTrip.year === parts.year &&
83
+ roundTrip.month === parts.month &&
84
+ roundTrip.day === parts.day &&
85
+ roundTrip.hour === hour &&
86
+ roundTrip.minute === minute
87
+ ? next
88
+ : undefined;
89
+ }
90
+
91
+ /** Retain the existing next-day minimum rule, validating its destination wall time. */
92
+ export function pickerTimeWithMinimum(
93
+ base: Date,
94
+ hour: number,
95
+ minute: number,
96
+ zone: string | null,
97
+ minDate?: Date
98
+ ): Date | undefined {
99
+ const next = pickerWallTime(base, hour, minute, zone);
100
+ if (!next || !minDate) return next;
101
+ if (zone) {
102
+ if (next.getTime() > minDate.getTime()) return next;
103
+ const minimum = getDatePartsInTimezone(minDate, zone);
104
+ return pickerWallTimeOnDay(
105
+ new Date(minimum.year, minimum.month - 1, minimum.day + 1),
106
+ hour,
107
+ minute,
108
+ zone
109
+ );
110
+ }
111
+ const sameDay =
112
+ pickerCalendarDate(next, null).getTime() ===
113
+ pickerCalendarDate(minDate, null).getTime();
114
+ if (
115
+ !sameDay ||
116
+ hour * 60 + minute > minDate.getHours() * 60 + minDate.getMinutes()
117
+ )
118
+ return next;
119
+ return pickerWallTimeOnDay(
120
+ new Date(next.getFullYear(), next.getMonth(), next.getDate() + 1),
121
+ hour,
122
+ minute,
123
+ null
124
+ );
125
+ }
126
+
127
+ export function createPickerTimeOptions(timeFormat: '12h' | '24h') {
128
+ return Array.from({ length: 96 }, (_, index) => {
129
+ const value = `${Math.floor(index / 4)
130
+ .toString()
131
+ .padStart(2, '0')}:${((index % 4) * 15).toString().padStart(2, '0')}`;
132
+ return {
133
+ value,
134
+ // Clock labels have no date or zone; constructing a browser-local Date
135
+ // would normalize 02:xx on a DST-gap day even for another calendar zone.
136
+ display:
137
+ timeFormat === '24h'
138
+ ? value
139
+ : `${Math.floor(index / 4) % 12 || 12}:${value.slice(3)} ${index < 48 ? 'AM' : 'PM'}`,
140
+ };
141
+ });
142
+ }
143
+
144
+ export function filterPickerTimeOptions(args: {
145
+ date?: Date;
146
+ minDate?: Date;
147
+ maxDate?: Date;
148
+ minTime?: string;
149
+ zone: string | null;
150
+ pattern: string;
151
+ timeFormat: '12h' | '24h';
152
+ options: { value: string; display: string }[];
153
+ }) {
154
+ const { date, minDate, maxDate, minTime, zone, pattern, timeFormat } = args;
155
+ let options = args.options;
156
+ if (date) {
157
+ options = options.filter(({ value }) => {
158
+ const [hour, minute] = value.split(':').map(Number);
159
+ return pickerWallTime(date, hour!, minute!, zone) !== undefined;
160
+ });
161
+ }
162
+ const sameDay = (other: Date) =>
163
+ date &&
164
+ pickerCalendarDate(date, zone).getTime() ===
165
+ pickerCalendarDate(other, zone).getTime();
166
+ if (minTime && minDate && sameDay(minDate)) {
167
+ options = options.filter((time) => time.value > minTime);
168
+ }
169
+ if (maxDate && sameDay(maxDate)) {
170
+ options = options.filter(
171
+ (time) => time.value < pickerTimeValue(maxDate, zone)
172
+ );
173
+ }
174
+ // Keep a pre-existing custom value visible; never change the stored instant
175
+ // merely because the value is outside the available fifteen-minute options.
176
+ if (date) {
177
+ const value = pickerTimeValue(date, zone);
178
+ if (!options.some((time) => time.value === value)) {
179
+ const display = zone
180
+ ? formatInTimezone(
181
+ date,
182
+ zone,
183
+ timeFormat === '24h' ? 'HH:mm' : 'h:mm A'
184
+ )
185
+ : format(date, pattern);
186
+ options = [...options, { value, display }].sort((a, b) =>
187
+ a.value.localeCompare(b.value)
188
+ );
189
+ }
190
+ }
191
+ return options;
192
+ }
193
+
194
+ export function pickerCalendarBounds(
195
+ minDate: Date | undefined,
196
+ maxDate: Date | undefined,
197
+ zone: string | null
198
+ ) {
199
+ return minDate || maxDate
200
+ ? [
201
+ ...(minDate ? [{ before: pickerCalendarDate(minDate, zone) }] : []),
202
+ ...(maxDate ? [{ after: pickerCalendarDate(maxDate, zone) }] : []),
203
+ ]
204
+ : undefined;
205
+ }