@tuturuuu/utils 0.24.0 → 0.24.2
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 +14 -0
- package/package.json +2 -2
- package/src/calendar-settings-resolver.test.ts +168 -0
- package/src/i18n-root-locale.test.ts +57 -0
- package/src/i18n-root-locale.ts +22 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/timeblock-helper.test.ts +381 -0
- package/src/timeblock-helper.ts +2 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.24.2](https://github.com/tutur3u/platform/compare/utils-v0.24.1...utils-v0.24.2) (2026-08-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **i18n:** preserve prerender locale fallback ([3a09b07](https://github.com/tutur3u/platform/commit/3a09b070abda729649f269310a70db78f9b3a1cc))
|
|
9
|
+
|
|
10
|
+
## [0.24.1](https://github.com/tutur3u/platform/compare/utils-v0.24.0...utils-v0.24.1) (2026-08-11)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **i18n:** keep request locale build-safe ([cc575a2](https://github.com/tutur3u/platform/commit/cc575a2e951ef8fb46a4b8593d0dcfcbe03cd4e7))
|
|
16
|
+
|
|
3
17
|
## [0.24.0](https://github.com/tutur3u/platform/compare/utils-v0.23.1...utils-v0.24.0) (2026-08-09)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.24.
|
|
4
|
+
"version": "0.24.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@tiptap/react": "3.29.2",
|
|
25
25
|
"@tuturuuu/google": "0.1.0",
|
|
26
26
|
"@tuturuuu/icons": "0.1.0",
|
|
27
|
-
"@tuturuuu/internal-api": "0.29.
|
|
27
|
+
"@tuturuuu/internal-api": "0.29.1",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.0",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.2",
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
detectLocaleFirstDay,
|
|
4
|
+
detectLocaleTimeFormat,
|
|
5
|
+
detectSystemTimezone,
|
|
6
|
+
firstDayToNumber,
|
|
7
|
+
numberToFirstDay,
|
|
8
|
+
resolveCalendarSettings,
|
|
9
|
+
resolveFirstDayOfWeek,
|
|
10
|
+
resolveTimeFormat,
|
|
11
|
+
resolveTimezone,
|
|
12
|
+
} from './calendar-settings-resolver';
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('calendar settings resolver', () => {
|
|
19
|
+
it.each([
|
|
20
|
+
['vi-VN', 'monday'],
|
|
21
|
+
['en-US', 'sunday'],
|
|
22
|
+
['en-CA', 'sunday'],
|
|
23
|
+
['ar-SA', 'saturday'],
|
|
24
|
+
['he-IL', 'saturday'],
|
|
25
|
+
['fr-FR', 'monday'],
|
|
26
|
+
['unsupported-tag', 'monday'],
|
|
27
|
+
] as const)('derives the first day for %s', (locale, expected) => {
|
|
28
|
+
expect(detectLocaleFirstDay(locale)).toBe(expected);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
{
|
|
33
|
+
label: 'user value',
|
|
34
|
+
user: { first_day_of_week: 'saturday' },
|
|
35
|
+
workspace: { first_day_of_week: 'sunday' },
|
|
36
|
+
locale: 'vi-VN',
|
|
37
|
+
expected: 'saturday',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
label: 'workspace fallback',
|
|
41
|
+
user: { first_day_of_week: 'auto' },
|
|
42
|
+
workspace: { first_day_of_week: 'sunday' },
|
|
43
|
+
locale: 'vi-VN',
|
|
44
|
+
expected: 'sunday',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: 'locale fallback for empty values',
|
|
48
|
+
user: { first_day_of_week: '' },
|
|
49
|
+
workspace: { first_day_of_week: null },
|
|
50
|
+
locale: 'ar-SA',
|
|
51
|
+
expected: 'saturday',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
label: 'locale fallback for null settings',
|
|
55
|
+
user: null,
|
|
56
|
+
workspace: null,
|
|
57
|
+
locale: 'en-US',
|
|
58
|
+
expected: 'sunday',
|
|
59
|
+
},
|
|
60
|
+
])(
|
|
61
|
+
'resolves first-day precedence from $label',
|
|
62
|
+
({ user, workspace, locale, expected }) => {
|
|
63
|
+
expect(resolveFirstDayOfWeek(user, workspace, locale)).toBe(expected);
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
it('resolves timezone precedence and fixed system fallback', () => {
|
|
68
|
+
const originalDateTimeFormat = Intl.DateTimeFormat;
|
|
69
|
+
vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(((
|
|
70
|
+
...args: ConstructorParameters<typeof Intl.DateTimeFormat>
|
|
71
|
+
) => {
|
|
72
|
+
if (args.length > 0) return new originalDateTimeFormat(...args);
|
|
73
|
+
return {
|
|
74
|
+
resolvedOptions: () => ({ timeZone: 'Pacific/Auckland' }),
|
|
75
|
+
} as Intl.DateTimeFormat;
|
|
76
|
+
}) as typeof Intl.DateTimeFormat);
|
|
77
|
+
|
|
78
|
+
expect(
|
|
79
|
+
resolveTimezone(
|
|
80
|
+
{ timezone: 'Asia/Ho_Chi_Minh' },
|
|
81
|
+
{ timezone: 'Europe/London' }
|
|
82
|
+
)
|
|
83
|
+
).toBe('Asia/Ho_Chi_Minh');
|
|
84
|
+
expect(
|
|
85
|
+
resolveTimezone({ timezone: 'auto' }, { timezone: 'Europe/London' })
|
|
86
|
+
).toBe('Europe/London');
|
|
87
|
+
expect(resolveTimezone({ timezone: '' }, { timezone: null })).toBe(
|
|
88
|
+
'Pacific/Auckland'
|
|
89
|
+
);
|
|
90
|
+
expect(detectSystemTimezone()).toBe('Pacific/Auckland');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('falls back to UTC when system timezone detection throws', () => {
|
|
94
|
+
vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
|
|
95
|
+
throw new Error('Intl unavailable');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(detectSystemTimezone()).toBe('UTC');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each([
|
|
102
|
+
['en-US', '1', '12h'],
|
|
103
|
+
['vi-VN', '13', '24h'],
|
|
104
|
+
] as const)(
|
|
105
|
+
'detects the %s time format from fixed Intl parts',
|
|
106
|
+
(locale, hour, expected) => {
|
|
107
|
+
vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
|
|
108
|
+
(() =>
|
|
109
|
+
({
|
|
110
|
+
formatToParts: () => [{ type: 'hour', value: hour }],
|
|
111
|
+
}) as Intl.DateTimeFormat) as typeof Intl.DateTimeFormat
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(detectLocaleTimeFormat(locale)).toBe(expected);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
it('uses locale-specific time-format fallbacks when Intl rejects a tag', () => {
|
|
119
|
+
vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => {
|
|
120
|
+
throw new RangeError('Unsupported locale');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(detectLocaleTimeFormat('en-invalid')).toBe('12h');
|
|
124
|
+
expect(detectLocaleTimeFormat('xx-invalid')).toBe('24h');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('prefers an explicit user time format over locale detection', () => {
|
|
128
|
+
expect(resolveTimeFormat({ time_format: '24h' }, 'en-US')).toBe('24h');
|
|
129
|
+
expect(resolveTimeFormat({ time_format: 'auto' }, 'en-US')).toBe(
|
|
130
|
+
detectLocaleTimeFormat('en-US')
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('resolves the complete settings object without changing precedence', () => {
|
|
135
|
+
expect(
|
|
136
|
+
resolveCalendarSettings(
|
|
137
|
+
{
|
|
138
|
+
timezone: 'Asia/Ho_Chi_Minh',
|
|
139
|
+
first_day_of_week: 'auto',
|
|
140
|
+
time_format: '24h',
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
timezone: 'Europe/London',
|
|
144
|
+
first_day_of_week: 'sunday',
|
|
145
|
+
},
|
|
146
|
+
'vi-VN'
|
|
147
|
+
)
|
|
148
|
+
).toEqual({
|
|
149
|
+
timezone: 'Asia/Ho_Chi_Minh',
|
|
150
|
+
firstDayOfWeek: 'sunday',
|
|
151
|
+
timeFormat: '24h',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it.each([
|
|
156
|
+
['sunday', 0],
|
|
157
|
+
['monday', 1],
|
|
158
|
+
['saturday', 6],
|
|
159
|
+
] as const)('converts %s to %i and back', (day, number) => {
|
|
160
|
+
expect(firstDayToNumber(day)).toBe(number);
|
|
161
|
+
expect(numberToFirstDay(number)).toBe(day);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('resolves auto and unsupported conversion values deterministically', () => {
|
|
165
|
+
expect(firstDayToNumber('auto', 'en-US')).toBe(0);
|
|
166
|
+
expect(numberToFirstDay(3)).toBe('monday');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
notFound: vi.fn(() => {
|
|
5
|
+
throw new Error('not found');
|
|
6
|
+
}),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
vi.mock('next/navigation', () => ({ notFound: mocks.notFound }));
|
|
10
|
+
|
|
11
|
+
import { resolveRequestLocale, resolveRootLocale } from './i18n-root-locale';
|
|
12
|
+
|
|
13
|
+
describe('resolveRequestLocale', () => {
|
|
14
|
+
it('accepts a supported request locale', () => {
|
|
15
|
+
expect(resolveRequestLocale(['en', 'vi'], 'vi', 'en')).toBe('vi');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses the configured default for missing prerender locale context', () => {
|
|
19
|
+
expect(resolveRequestLocale(['en', 'vi'], undefined, 'en')).toBe('en');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('uses the configured default for invalid route candidates', () => {
|
|
23
|
+
expect(resolveRequestLocale(['en', 'vi'], 'unknown', 'en')).toBe('en');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('resolveRootLocale', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mocks.notFound.mockClear();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('accepts an explicit locale candidate', async () => {
|
|
33
|
+
await expect(resolveRootLocale(['en', 'vi'], 'en')).resolves.toBe('en');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('accepts an awaited request locale candidate', async () => {
|
|
37
|
+
const requestLocale = Promise.resolve('vi');
|
|
38
|
+
|
|
39
|
+
await expect(
|
|
40
|
+
resolveRootLocale(['en', 'vi'], await requestLocale)
|
|
41
|
+
).resolves.toBe('vi');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a missing locale candidate', async () => {
|
|
45
|
+
await expect(resolveRootLocale(['en', 'vi'], undefined)).rejects.toThrow(
|
|
46
|
+
'not found'
|
|
47
|
+
);
|
|
48
|
+
expect(mocks.notFound).toHaveBeenCalledOnce();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects unknown locale candidates', async () => {
|
|
52
|
+
await expect(resolveRootLocale(['en', 'vi'], 'unknown')).rejects.toThrow(
|
|
53
|
+
'not found'
|
|
54
|
+
);
|
|
55
|
+
expect(mocks.notFound).toHaveBeenCalledOnce();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { notFound } from 'next/navigation';
|
|
2
|
+
|
|
3
|
+
export function resolveRequestLocale<const Locale extends string>(
|
|
4
|
+
locales: readonly Locale[],
|
|
5
|
+
locale: string | undefined,
|
|
6
|
+
defaultLocale: Locale
|
|
7
|
+
): Locale {
|
|
8
|
+
return locales.includes(locale as Locale)
|
|
9
|
+
? (locale as Locale)
|
|
10
|
+
: defaultLocale;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveRootLocale<const Locale extends string>(
|
|
14
|
+
locales: readonly Locale[],
|
|
15
|
+
locale?: string
|
|
16
|
+
): Promise<Locale> {
|
|
17
|
+
if (!locales.includes(locale as Locale)) {
|
|
18
|
+
notFound();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return locale as Locale;
|
|
22
|
+
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.36.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.36.2'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.36.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.36.2'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import type { Timeblock } from '@tuturuuu/types/primitives/Timeblock';
|
|
3
|
+
import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
addTimeblocks,
|
|
6
|
+
datesToDateMatrix,
|
|
7
|
+
datesToTimeMatrix,
|
|
8
|
+
durationToTimeblocks,
|
|
9
|
+
getDateStrings,
|
|
10
|
+
removeTimeblocks,
|
|
11
|
+
} from './timeblock-helper';
|
|
12
|
+
|
|
13
|
+
const originalTimezone = process.env.TZ;
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
process.env.TZ = 'UTC';
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.setSystemTime(new Date('2024-01-15T12:00:00Z'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
vi.useRealTimers();
|
|
23
|
+
process.env.TZ = originalTimezone;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function date(value: string) {
|
|
27
|
+
return new Date(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function block(
|
|
31
|
+
blockDate: string,
|
|
32
|
+
startTime: string,
|
|
33
|
+
endTime: string,
|
|
34
|
+
tentative = false
|
|
35
|
+
): Timeblock {
|
|
36
|
+
return {
|
|
37
|
+
date: blockDate,
|
|
38
|
+
end_time: endTime,
|
|
39
|
+
start_time: startTime,
|
|
40
|
+
tentative,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalize(timeblocks: Timeblock[]) {
|
|
45
|
+
return timeblocks.map(
|
|
46
|
+
({ date: blockDate, end_time, start_time, tentative }) =>
|
|
47
|
+
block(blockDate, start_time, end_time, tentative)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('timeblock temporal contract', () => {
|
|
52
|
+
test('uses a fixed UTC clock for deterministic date and time matrices', () => {
|
|
53
|
+
expect(process.env.TZ).toBe('UTC');
|
|
54
|
+
expect(new Date().toISOString()).toBe('2024-01-15T12:00:00.000Z');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('formats dates in the fixed runtime timezone', () => {
|
|
58
|
+
expect(
|
|
59
|
+
getDateStrings([
|
|
60
|
+
date('2024-03-15T23:30:00Z'),
|
|
61
|
+
date('2024-03-16T00:30:00Z'),
|
|
62
|
+
])
|
|
63
|
+
).toEqual(['2024-03-15', '2024-03-16']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('rejects empty date and time matrices', () => {
|
|
67
|
+
expect(() => datesToDateMatrix([])).toThrow('Invalid input');
|
|
68
|
+
expect(() => datesToTimeMatrix([])).toThrow('Invalid input');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('normalizes reverse date endpoints and includes the final grid cell', () => {
|
|
72
|
+
const matrix = datesToDateMatrix([
|
|
73
|
+
date('2024-03-17T19:00:00Z'),
|
|
74
|
+
date('2024-03-15T08:00:00Z'),
|
|
75
|
+
]);
|
|
76
|
+
expect(matrix.soonest.toISOString()).toBe('2024-03-15T08:00:00.000Z');
|
|
77
|
+
expect(matrix.latest.toISOString()).toBe('2024-03-17T19:15:00.000Z');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('normalizes reverse time endpoints independently of their dates', () => {
|
|
81
|
+
const matrix = datesToTimeMatrix([
|
|
82
|
+
date('2024-03-15T19:00:00Z'),
|
|
83
|
+
date('2024-03-17T08:00:00Z'),
|
|
84
|
+
]);
|
|
85
|
+
expect(matrix.soonest.format('HH:mm:ssZ')).toBe('08:00:00+00:00');
|
|
86
|
+
expect(matrix.latest.format('HH:mm:ssZ')).toBe('19:00:00+00:00');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const durationCases: Array<{
|
|
91
|
+
dates: Date[];
|
|
92
|
+
expected: Timeblock[];
|
|
93
|
+
name: string;
|
|
94
|
+
tentative?: boolean;
|
|
95
|
+
}> = [
|
|
96
|
+
{ dates: [], expected: [], name: 'empty selection' },
|
|
97
|
+
{
|
|
98
|
+
dates: [date('2024-03-15T08:00:00Z')],
|
|
99
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '08:15:00+00:00')],
|
|
100
|
+
name: 'one selected cell',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T08:00:00Z')],
|
|
104
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '08:15:00+00:00')],
|
|
105
|
+
name: 'equal endpoints',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
dates: [
|
|
109
|
+
date('2024-03-15T08:00:00Z'),
|
|
110
|
+
date('2024-03-15T08:15:00Z'),
|
|
111
|
+
date('2024-03-15T08:30:00Z'),
|
|
112
|
+
],
|
|
113
|
+
expected: [],
|
|
114
|
+
name: 'more than two endpoints',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T19:00:00Z')],
|
|
118
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
119
|
+
name: 'same-day forward selection',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
dates: [date('2024-03-15T19:00:00Z'), date('2024-03-15T08:00:00Z')],
|
|
123
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
124
|
+
name: 'same-day reverse selection',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-17T19:00:00Z')],
|
|
128
|
+
expected: ['15', '16', '17'].map((day) =>
|
|
129
|
+
block(`2024-03-${day}`, '08:00:00+00:00', '19:15:00+00:00')
|
|
130
|
+
),
|
|
131
|
+
name: 'multi-day forward selection',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
dates: [date('2024-03-17T19:00:00Z'), date('2024-03-15T08:00:00Z')],
|
|
135
|
+
expected: ['15', '16', '17'].map((day) =>
|
|
136
|
+
block(`2024-03-${day}`, '08:00:00+00:00', '19:15:00+00:00')
|
|
137
|
+
),
|
|
138
|
+
name: 'multi-day reverse selection',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
dates: [
|
|
142
|
+
date('2024-03-15T08:00:00+02:00'),
|
|
143
|
+
date('2024-03-17T19:00:00+02:00'),
|
|
144
|
+
],
|
|
145
|
+
expected: ['15', '16', '17'].map((day) =>
|
|
146
|
+
block(`2024-03-${day}`, '06:00:00+00:00', '17:15:00+00:00')
|
|
147
|
+
),
|
|
148
|
+
name: 'positive input offset normalized to UTC',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
dates: [
|
|
152
|
+
date('2024-03-15T08:00:00-02:00'),
|
|
153
|
+
date('2024-03-17T19:00:00-02:00'),
|
|
154
|
+
],
|
|
155
|
+
expected: ['15', '16', '17'].map((day) =>
|
|
156
|
+
block(`2024-03-${day}`, '10:00:00+00:00', '21:15:00+00:00')
|
|
157
|
+
),
|
|
158
|
+
name: 'negative input offset normalized to UTC',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
dates: [
|
|
162
|
+
date('2024-03-09T08:00:00-05:00'),
|
|
163
|
+
date('2024-03-11T09:00:00-04:00'),
|
|
164
|
+
],
|
|
165
|
+
expected: ['09', '10', '11'].map((day) =>
|
|
166
|
+
block(`2024-03-${day}`, '13:00:00+00:00', '13:15:00+00:00', true)
|
|
167
|
+
),
|
|
168
|
+
name: 'DST-adjacent explicit offsets normalized by instant',
|
|
169
|
+
tentative: true,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
describe('durationToTimeblocks', () => {
|
|
174
|
+
test.each(durationCases)('$name', ({ dates, expected, tentative }) => {
|
|
175
|
+
expect(normalize(durationToTimeblocks(dates, tentative ?? false))).toEqual(
|
|
176
|
+
expected
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const addCases: Array<{
|
|
182
|
+
dates: Date[];
|
|
183
|
+
expected: Timeblock[];
|
|
184
|
+
name: string;
|
|
185
|
+
previous: Timeblock[];
|
|
186
|
+
tentative?: boolean;
|
|
187
|
+
}> = [
|
|
188
|
+
{ dates: [], expected: [], name: 'empty state and selection', previous: [] },
|
|
189
|
+
{
|
|
190
|
+
dates: [],
|
|
191
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
192
|
+
name: 'empty selection preserves state',
|
|
193
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
dates: [date('2024-03-16T10:00:00Z'), date('2024-03-16T15:15:00Z')],
|
|
197
|
+
expected: [
|
|
198
|
+
block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00'),
|
|
199
|
+
block('2024-03-16', '10:00:00+00:00', '15:30:00+00:00'),
|
|
200
|
+
],
|
|
201
|
+
name: 'different day sorted after existing state',
|
|
202
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
dates: [date('2024-03-15T08:15:00Z'), date('2024-03-15T08:30:00Z')],
|
|
206
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
207
|
+
name: 'contained overlap',
|
|
208
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
dates: [date('2024-03-15T09:00:00Z'), date('2024-03-15T09:00:00Z')],
|
|
212
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '09:15:00+00:00')],
|
|
213
|
+
name: 'adjacent selection merges',
|
|
214
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
dates: [date('2024-03-15T10:00:00Z'), date('2024-03-15T10:00:00Z')],
|
|
218
|
+
expected: [
|
|
219
|
+
block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00'),
|
|
220
|
+
block('2024-03-15', '10:00:00+00:00', '10:15:00+00:00'),
|
|
221
|
+
],
|
|
222
|
+
name: 'disjoint same-day selection stays separate',
|
|
223
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
dates: [date('2024-03-15T08:30:00Z'), date('2024-03-15T10:00:00Z')],
|
|
227
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '10:15:00+00:00')],
|
|
228
|
+
name: 'right overlap extends through final cell',
|
|
229
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00')],
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T09:00:00Z')],
|
|
233
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '10:00:00+00:00')],
|
|
234
|
+
name: 'left overlap normalizes reverse coverage',
|
|
235
|
+
previous: [block('2024-03-15', '09:00:00+00:00', '10:00:00+00:00')],
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
dates: [date('2024-03-15T08:30:00Z'), date('2024-03-15T09:00:00Z')],
|
|
239
|
+
expected: [
|
|
240
|
+
block('2024-03-15', '08:00:00+00:00', '08:30:00+00:00'),
|
|
241
|
+
block('2024-03-15', '08:30:00+00:00', '09:15:00+00:00', true),
|
|
242
|
+
block('2024-03-15', '09:15:00+00:00', '10:00:00+00:00'),
|
|
243
|
+
],
|
|
244
|
+
name: 'tentative overlap splits committed state',
|
|
245
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '10:00:00+00:00')],
|
|
246
|
+
tentative: true,
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
describe('addTimeblocks', () => {
|
|
251
|
+
test.each(addCases)('$name', ({ dates, expected, previous, tentative }) => {
|
|
252
|
+
expect(
|
|
253
|
+
normalize(addTimeblocks(previous, dates, tentative ?? false))
|
|
254
|
+
).toEqual(expected);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const baseHour = block('2024-03-15', '08:00:00+00:00', '09:00:00+00:00');
|
|
259
|
+
|
|
260
|
+
const removalCases: Array<{
|
|
261
|
+
dates: Date[];
|
|
262
|
+
expected: Timeblock[];
|
|
263
|
+
name: string;
|
|
264
|
+
previous: Timeblock[];
|
|
265
|
+
}> = [
|
|
266
|
+
{ dates: [], expected: [], name: 'empty state and selection', previous: [] },
|
|
267
|
+
{
|
|
268
|
+
dates: [],
|
|
269
|
+
expected: [baseHour],
|
|
270
|
+
name: 'empty selection preserves state',
|
|
271
|
+
previous: [baseHour],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
dates: [date('2024-03-15T08:15:00Z')],
|
|
275
|
+
expected: [
|
|
276
|
+
block('2024-03-15', '08:00:00+00:00', '08:15:00+00:00'),
|
|
277
|
+
block('2024-03-15', '08:30:00+00:00', '09:00:00+00:00'),
|
|
278
|
+
],
|
|
279
|
+
name: 'one-date shape removes exactly one cell',
|
|
280
|
+
previous: [baseHour],
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
dates: [date('2024-03-15T08:15:00Z'), date('2024-03-15T08:15:00Z')],
|
|
284
|
+
expected: [
|
|
285
|
+
block('2024-03-15', '08:00:00+00:00', '08:15:00+00:00'),
|
|
286
|
+
block('2024-03-15', '08:30:00+00:00', '09:00:00+00:00'),
|
|
287
|
+
],
|
|
288
|
+
name: 'equal endpoints remove exactly one cell',
|
|
289
|
+
previous: [baseHour],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
dates: [
|
|
293
|
+
date('2024-03-15T08:00:00Z'),
|
|
294
|
+
date('2024-03-15T08:30:00Z'),
|
|
295
|
+
date('2024-03-15T08:45:00Z'),
|
|
296
|
+
],
|
|
297
|
+
expected: [block('2024-03-15', '08:15:00+00:00', '08:30:00+00:00')],
|
|
298
|
+
name: 'per-date shape removes one cell per selected instant',
|
|
299
|
+
previous: [baseHour],
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T12:00:00Z')],
|
|
303
|
+
expected: [block('2024-03-15', '12:15:00+00:00', '19:15:00+00:00')],
|
|
304
|
+
name: 'first range split',
|
|
305
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
dates: [date('2024-03-15T12:00:00Z'), date('2024-03-15T19:00:00Z')],
|
|
309
|
+
expected: [block('2024-03-15', '08:00:00+00:00', '12:00:00+00:00')],
|
|
310
|
+
name: 'last range split',
|
|
311
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
dates: [date('2024-03-15T10:00:00Z'), date('2024-03-15T15:15:00Z')],
|
|
315
|
+
expected: [
|
|
316
|
+
block('2024-03-15', '08:00:00+00:00', '10:00:00+00:00'),
|
|
317
|
+
block('2024-03-15', '15:30:00+00:00', '19:15:00+00:00'),
|
|
318
|
+
],
|
|
319
|
+
name: 'middle range split',
|
|
320
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T19:00:00Z')],
|
|
324
|
+
expected: [],
|
|
325
|
+
name: 'full range removal',
|
|
326
|
+
previous: [block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00')],
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-17T08:45:00Z')],
|
|
330
|
+
expected: [block('2024-03-15', '10:00:00+00:00', '11:00:00+00:00')],
|
|
331
|
+
name: 'same slot across multiple days',
|
|
332
|
+
previous: [
|
|
333
|
+
...['15', '16', '17'].map((day) =>
|
|
334
|
+
block(`2024-03-${day}`, '08:00:00+00:00', '09:00:00+00:00')
|
|
335
|
+
),
|
|
336
|
+
block('2024-03-15', '10:00:00+00:00', '11:00:00+00:00'),
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-15T19:00:00Z')],
|
|
341
|
+
expected: [block('2024-03-16', '08:00:00+00:00', '19:15:00+00:00')],
|
|
342
|
+
name: 'full removal across adjacent blocks on one day',
|
|
343
|
+
previous: [
|
|
344
|
+
block('2024-03-15', '08:00:00+00:00', '12:00:00+00:00'),
|
|
345
|
+
block('2024-03-15', '12:00:00+00:00', '19:15:00+00:00'),
|
|
346
|
+
block('2024-03-16', '08:00:00+00:00', '19:15:00+00:00'),
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
dates: [date('2024-03-15T08:00:00Z'), date('2024-03-16T19:00:00Z')],
|
|
351
|
+
expected: [],
|
|
352
|
+
name: 'multi-day full removal',
|
|
353
|
+
previous: [
|
|
354
|
+
block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00'),
|
|
355
|
+
block('2024-03-16', '08:00:00+00:00', '19:15:00+00:00'),
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
dates: [date('2024-03-16T19:00:00Z'), date('2024-03-15T08:00:00Z')],
|
|
360
|
+
expected: [],
|
|
361
|
+
name: 'reverse multi-day full removal',
|
|
362
|
+
previous: [
|
|
363
|
+
block('2024-03-15', '08:00:00+00:00', '19:15:00+00:00'),
|
|
364
|
+
block('2024-03-16', '08:00:00+00:00', '19:15:00+00:00'),
|
|
365
|
+
],
|
|
366
|
+
},
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
describe('removeTimeblocks', () => {
|
|
370
|
+
test.each(removalCases)('$name', ({ dates, expected, previous }) => {
|
|
371
|
+
expect(normalize(removeTimeblocks(previous, dates))).toEqual(expected);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('state-machine suites cannot regain static skips or todos', () => {
|
|
376
|
+
const source = readFileSync(__filename, 'utf8');
|
|
377
|
+
expect(source).not.toMatch(/\b(?:test|it|describe)\.(?:skip|todo)\s*\(/u);
|
|
378
|
+
expect(
|
|
379
|
+
durationCases.length + addCases.length + removalCases.length
|
|
380
|
+
).toBeGreaterThanOrEqual(25);
|
|
381
|
+
});
|
package/src/timeblock-helper.ts
CHANGED
|
@@ -227,9 +227,8 @@ export function removeTimeblocks(
|
|
|
227
227
|
.set('hour', soonestTime.hour())
|
|
228
228
|
.set('minute', soonestTime.minute())
|
|
229
229
|
.set('second', 0);
|
|
230
|
-
const removalEnd = removalStart.add(15, 'minutes');
|
|
231
230
|
|
|
232
|
-
return removeTimeblocksInRange(prevTimeblocks, removalStart,
|
|
231
|
+
return removeTimeblocksInRange(prevTimeblocks, removalStart, removalStart);
|
|
233
232
|
}
|
|
234
233
|
|
|
235
234
|
// Handle multi-day removal - process each day separately
|
|
@@ -298,8 +297,7 @@ export function removeTimeblocks(
|
|
|
298
297
|
|
|
299
298
|
for (const date of sortedDates) {
|
|
300
299
|
const removalStart = dayjs(date).set('second', 0);
|
|
301
|
-
|
|
302
|
-
result = removeTimeblocksInRange(result, removalStart, removalEnd);
|
|
300
|
+
result = removeTimeblocksInRange(result, removalStart, removalStart);
|
|
303
301
|
}
|
|
304
302
|
|
|
305
303
|
return result;
|