@tuturuuu/ui 0.36.1 → 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.
- package/CHANGELOG.md +31 -0
- package/package.json +7 -5
- package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
- package/src/components/ui/date-time-picker-values.ts +205 -0
- package/src/components/ui/date-time-picker.tsx +70 -167
- package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
- package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
- package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
- package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
- package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
- package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
- package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
- package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
- package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
- package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
- package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
- package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
- package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
- package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
- package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
- package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
- package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
- package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
- package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
- package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
- package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
- package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
- package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
- package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
- package/src/hooks/calendar-event-lookup.test.ts +39 -0
- package/src/hooks/calendar-event-lookup.ts +22 -8
- package/src/hooks/calendar-sync-range.test.ts +92 -0
- package/src/hooks/calendar-sync-range.ts +40 -0
- package/src/hooks/use-calendar-clock.ts +49 -0
- package/src/hooks/use-calendar-day-zone.tsx +12 -0
- package/src/hooks/use-calendar-sync.tsx +42 -80
- package/src/hooks/use-calendar.tsx +3 -3
- package/src/lib/calendar-day.ts +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
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
|
+
|
|
17
|
+
## [0.37.0](https://github.com/tutur3u/platform/compare/ui-v0.36.1...ui-v0.37.0) (2026-09-29)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
### Features
|
|
21
|
+
|
|
22
|
+
* **auth:** enforce required account MFA lifecycle ([#5448](https://github.com/tutur3u/platform/issues/5448)) ([d7a5ed4](https://github.com/tutur3u/platform/commit/d7a5ed453963e619bd1dc3bd8c8a18c5e027317c))
|
|
23
|
+
* **notes:** add archive views and structured editing ([1c78aa2](https://github.com/tutur3u/platform/commit/1c78aa292ebca25660c1ec7464cd6cc02144c82a))
|
|
24
|
+
* **notes:** archive views and structured editing ([#5576](https://github.com/tutur3u/platform/issues/5576)) ([a67b186](https://github.com/tutur3u/platform/commit/a67b186318153cad8b10b71bbfc9335b80d0768a))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **calendar:** treat working locations as non-reminder events ([454e666](https://github.com/tutur3u/platform/commit/454e666a6465e3debd06260aedc1de7b96ab5d67)) ([#5511](https://github.com/tutur3u/platform/issues/5511)) ([6508ad6](https://github.com/tutur3u/platform/commit/6508ad681aaa174b186022fcbd526e20be682973))
|
|
30
|
+
* **education:** label satellite navigation in Learn and Teach ([7728149](https://github.com/tutur3u/platform/commit/77281496b86def6f02afaaabea0fb39a89557717)) ([#5536](https://github.com/tutur3u/platform/issues/5536)) ([d76aa49](https://github.com/tutur3u/platform/commit/d76aa49d93303156d900c872ada803c43e1c8530))
|
|
31
|
+
* **finance:** count recurring sessions in subscription previews ([94119f5](https://github.com/tutur3u/platform/commit/94119f5126e2c48d7e096a572c5957610ce67724)) ([#5541](https://github.com/tutur3u/platform/issues/5541)) ([e645a51](https://github.com/tutur3u/platform/commit/e645a51b0c9006cc1baf66e9846c5656b2063220))
|
|
32
|
+
* **finance:** respect class join dates in pending tuition ([1aaaff0](https://github.com/tutur3u/platform/commit/1aaaff054d9b87c8ada88af6bdcadb840e54a186)) ([#5618](https://github.com/tutur3u/platform/issues/5618)) ([4a0d26c](https://github.com/tutur3u/platform/commit/4a0d26ca1246cd4075e9fcc6ad3dd7a093861766))
|
|
33
|
+
|
|
3
34
|
## [0.36.1](https://github.com/tutur3u/platform/compare/ui-v0.36.0...ui-v0.36.1) (2026-09-29)
|
|
4
35
|
|
|
5
36
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -88,12 +88,12 @@
|
|
|
88
88
|
"@tiptap/react": "3.31.3",
|
|
89
89
|
"@tiptap/starter-kit": "3.31.3",
|
|
90
90
|
"@tuturuuu/ai": "0.16.1",
|
|
91
|
-
"@tuturuuu/apis": "0.
|
|
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.
|
|
94
|
+
"@tuturuuu/internal-api": "0.48.0",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.2",
|
|
96
|
-
"@tuturuuu/utils": "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.
|
|
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
|
+
}
|