@tuturuuu/ui 0.26.0 → 0.26.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 +13 -0
- package/README.md +29 -7
- package/package.json +2 -8
- package/src/components/ui/calendar-app/hooks/use-calendar-settings.test.ts +32 -0
- package/src/components/ui/custom/nav-link.test.tsx +47 -0
- package/src/components/ui/custom/nav-link.tsx +28 -3
- package/src/components/ui/legacy/polls/poll-display.test.tsx +118 -0
- package/src/components/ui/legacy/polls/poll-display.tsx +8 -0
- package/src/lib/calendar-settings-resolver.ts +1 -200
- package/src/readme-contract.test.tsx +57 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.26.1](https://github.com/tutur3u/platform/compare/ui-v0.26.0...ui-v0.26.1) (2026-08-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **vercel:** harden navigation prefetch policy ([b8cf61c](https://github.com/tutur3u/platform/commit/b8cf61c86727817faff1f28da768fee4b3e52e68))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Performance Improvements
|
|
12
|
+
|
|
13
|
+
* **vercel:** reduce speculative app requests ([423e240](https://github.com/tutur3u/platform/commit/423e2401c16b2cad4785ba46a08bf5b3d1c8a749))
|
|
14
|
+
* **vercel:** reduce speculative app requests ([#5116](https://github.com/tutur3u/platform/issues/5116)) ([9e2b59b](https://github.com/tutur3u/platform/commit/9e2b59bcaa0bbc7a593ff91e2d2331aca4ab6595))
|
|
15
|
+
|
|
3
16
|
## [0.26.0](https://github.com/tutur3u/platform/compare/ui-v0.25.3...ui-v0.26.0) (2026-08-09)
|
|
4
17
|
|
|
5
18
|
|
package/README.md
CHANGED
|
@@ -6,12 +6,11 @@ Shared UI components and design system for Tuturuuu Platform.
|
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install @tuturuuu/ui
|
|
9
|
-
# or
|
|
10
|
-
yarn add @tuturuuu/ui
|
|
11
|
-
# or
|
|
12
|
-
bun add @tuturuuu/ui
|
|
13
9
|
```
|
|
14
10
|
|
|
11
|
+
Using another package manager? Run `pnpm add @tuturuuu/ui`,
|
|
12
|
+
`yarn add @tuturuuu/ui`, or `bun add @tuturuuu/ui` instead.
|
|
13
|
+
|
|
15
14
|
The public package depends on other published Tuturuuu packages, including
|
|
16
15
|
`@tuturuuu/apis` and `@tuturuuu/ai`. Release those dependencies first, then
|
|
17
16
|
publish UI from the same production commit so standalone apps can install
|
|
@@ -19,18 +18,41 @@ publish UI from the same production commit so standalone apps can install
|
|
|
19
18
|
|
|
20
19
|
## Usage
|
|
21
20
|
|
|
21
|
+
Import the shared stylesheet once from your app's root layout or entry point:
|
|
22
|
+
|
|
22
23
|
```typescript
|
|
23
|
-
import
|
|
24
|
+
import '@tuturuuu/ui/globals.css';
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then import each component from its public subpath:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
31
|
+
import {
|
|
32
|
+
Card,
|
|
33
|
+
CardContent,
|
|
34
|
+
CardHeader,
|
|
35
|
+
CardTitle,
|
|
36
|
+
} from '@tuturuuu/ui/card';
|
|
24
37
|
|
|
25
38
|
export default function MyComponent() {
|
|
26
39
|
return (
|
|
27
40
|
<Card>
|
|
28
|
-
<
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle>Welcome to Tuturuuu</CardTitle>
|
|
43
|
+
</CardHeader>
|
|
44
|
+
<CardContent>
|
|
45
|
+
<Button>Get started</Button>
|
|
46
|
+
</CardContent>
|
|
29
47
|
</Card>
|
|
30
|
-
)
|
|
48
|
+
);
|
|
31
49
|
}
|
|
32
50
|
```
|
|
33
51
|
|
|
52
|
+
The package exports TypeScript and TSX source files. Configure your framework
|
|
53
|
+
or bundler to transpile `@tuturuuu/ui` and its published Tuturuuu dependencies
|
|
54
|
+
when it does not transpile package source automatically.
|
|
55
|
+
|
|
34
56
|
## Features
|
|
35
57
|
|
|
36
58
|
- Fully accessible components
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@tuturuuu/icons": "0.1.0",
|
|
93
93
|
"@tuturuuu/internal-api": "0.29.0",
|
|
94
94
|
"@tuturuuu/supabase": "0.5.0",
|
|
95
|
-
"@tuturuuu/utils": "0.24.
|
|
95
|
+
"@tuturuuu/utils": "0.24.1",
|
|
96
96
|
"@types/debug": "^4.1.13",
|
|
97
97
|
"browser-image-compression": "^2.0.2",
|
|
98
98
|
"class-variance-authority": "^0.7.1",
|
|
@@ -396,12 +396,6 @@
|
|
|
396
396
|
},
|
|
397
397
|
"./calendar-app/hooks/*": "./src/components/ui/calendar-app/hooks/*.ts",
|
|
398
398
|
"./calendar-app/components/*": "./src/components/ui/calendar-app/components/*.tsx",
|
|
399
|
-
"./calendar-app/components/sidebar/*": "./src/components/ui/calendar-app/components/sidebar/*.tsx",
|
|
400
|
-
"./calendar-app/components/time-tracker": {
|
|
401
|
-
"types": "./src/components/ui/calendar-app/components/time-tracker/index.tsx",
|
|
402
|
-
"import": "./src/components/ui/calendar-app/components/time-tracker/index.tsx"
|
|
403
|
-
},
|
|
404
|
-
"./calendar-app/components/time-tracker/*": "./src/components/ui/calendar-app/components/time-tracker/*.tsx",
|
|
405
399
|
"./custom/icon-picker": {
|
|
406
400
|
"types": "./src/components/ui/custom/icon-picker/index.ts",
|
|
407
401
|
"import": "./src/components/ui/custom/icon-picker/index.ts"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveFirstDayOfWeek as resolveCanonicalFirstDayOfWeek,
|
|
3
|
+
resolveTimezone as resolveCanonicalTimezone,
|
|
4
|
+
} from '@tuturuuu/utils/calendar-settings-resolver';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
resolveFirstDayOfWeek,
|
|
8
|
+
resolveTimezone,
|
|
9
|
+
} from '../../../../lib/calendar-settings-resolver';
|
|
10
|
+
|
|
11
|
+
describe('shared calendar settings resolver integration', () => {
|
|
12
|
+
it('keeps the UI compatibility path on the canonical resolver', () => {
|
|
13
|
+
expect(resolveTimezone).toBe(resolveCanonicalTimezone);
|
|
14
|
+
expect(resolveFirstDayOfWeek).toBe(resolveCanonicalFirstDayOfWeek);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('prefers saved user values and preserves locale fallback', () => {
|
|
18
|
+
expect(
|
|
19
|
+
resolveTimezone(
|
|
20
|
+
{ timezone: 'Asia/Ho_Chi_Minh' },
|
|
21
|
+
{ timezone: 'Europe/London' }
|
|
22
|
+
)
|
|
23
|
+
).toBe('Asia/Ho_Chi_Minh');
|
|
24
|
+
expect(
|
|
25
|
+
resolveFirstDayOfWeek(
|
|
26
|
+
{ first_day_of_week: 'auto' },
|
|
27
|
+
{ first_day_of_week: 'auto' },
|
|
28
|
+
'vi-VN'
|
|
29
|
+
)
|
|
30
|
+
).toBe('monday');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -7,10 +7,12 @@ import { NavLink } from './nav-link';
|
|
|
7
7
|
|
|
8
8
|
const navigationState = vi.hoisted(() => ({
|
|
9
9
|
pathname: '/personal/tasks',
|
|
10
|
+
prefetch: vi.fn(),
|
|
10
11
|
}));
|
|
11
12
|
|
|
12
13
|
vi.mock('next/navigation', () => ({
|
|
13
14
|
usePathname: () => navigationState.pathname,
|
|
15
|
+
useRouter: () => ({ prefetch: navigationState.prefetch }),
|
|
14
16
|
}));
|
|
15
17
|
|
|
16
18
|
vi.mock('next-intl', () => ({
|
|
@@ -51,9 +53,54 @@ function renderNavLink(
|
|
|
51
53
|
describe('NavLink', () => {
|
|
52
54
|
beforeEach(() => {
|
|
53
55
|
navigationState.pathname = '/personal/tasks';
|
|
56
|
+
navigationState.prefetch.mockReset();
|
|
54
57
|
vi.restoreAllMocks();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it('prefetches internal routes only after navigation intent', () => {
|
|
61
|
+
renderNavLink({
|
|
62
|
+
href: '/personal/tasks/boards',
|
|
63
|
+
title: 'Boards',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const link = screen.getByRole('link', { name: 'Boards' });
|
|
67
|
+
expect(navigationState.prefetch).not.toHaveBeenCalled();
|
|
68
|
+
|
|
69
|
+
fireEvent.mouseEnter(link);
|
|
70
|
+
fireEvent.focus(link);
|
|
71
|
+
|
|
72
|
+
expect(navigationState.prefetch).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(navigationState.prefetch).toHaveBeenCalledWith(
|
|
74
|
+
'/personal/tasks/boards'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not prefetch external routes', () => {
|
|
79
|
+
renderNavLink({
|
|
80
|
+
external: true,
|
|
81
|
+
href: 'https://docs.tuturuuu.com',
|
|
82
|
+
title: 'Docs',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
fireEvent.mouseEnter(screen.getByRole('link', { name: 'Docs' }));
|
|
86
|
+
|
|
87
|
+
expect(navigationState.prefetch).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it.each([
|
|
91
|
+
['protocol-relative', '//docs.tuturuuu.com/guide'],
|
|
92
|
+
['email', 'mailto:support@tuturuuu.com'],
|
|
93
|
+
['telephone', 'tel:+84123456789'],
|
|
94
|
+
])('does not prefetch %s URLs', (_label, href) => {
|
|
95
|
+
renderNavLink({ href, title: 'External destination' });
|
|
96
|
+
|
|
97
|
+
fireEvent.mouseEnter(
|
|
98
|
+
screen.getByRole('link', { name: 'External destination' })
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(navigationState.prefetch).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
57
104
|
it('matches wildcard aliases even when the primary route is exact', () => {
|
|
58
105
|
navigationState.pathname = '/personal/tasks/boards/board-1';
|
|
59
106
|
|
|
@@ -12,9 +12,9 @@ import {
|
|
|
12
12
|
import { getWorkspaceConfigIdList } from '@tuturuuu/internal-api';
|
|
13
13
|
import { cn } from '@tuturuuu/utils/format';
|
|
14
14
|
import Link from 'next/link';
|
|
15
|
-
import { usePathname } from 'next/navigation';
|
|
15
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
16
16
|
import { useTranslations } from 'next-intl';
|
|
17
|
-
import { useState } from 'react';
|
|
17
|
+
import { useRef, useState } from 'react';
|
|
18
18
|
import { Badge } from '../badge';
|
|
19
19
|
import { Button } from '../button';
|
|
20
20
|
import {
|
|
@@ -58,7 +58,7 @@ function getComparablePath(
|
|
|
58
58
|
? new URL(target)
|
|
59
59
|
: new URL(target, base);
|
|
60
60
|
|
|
61
|
-
if (
|
|
61
|
+
if (url.origin !== base) return null;
|
|
62
62
|
|
|
63
63
|
return url.pathname;
|
|
64
64
|
} catch {
|
|
@@ -103,6 +103,7 @@ export function NavLink({
|
|
|
103
103
|
}: NavLinkProps) {
|
|
104
104
|
const t = useTranslations();
|
|
105
105
|
const pathname = usePathname();
|
|
106
|
+
const router = useRouter();
|
|
106
107
|
const {
|
|
107
108
|
title,
|
|
108
109
|
icon,
|
|
@@ -338,6 +339,27 @@ export function NavLink({
|
|
|
338
339
|
})()
|
|
339
340
|
: href;
|
|
340
341
|
|
|
342
|
+
const prefetchedHrefRef = useRef<string | null>(null);
|
|
343
|
+
const prefetchOnIntent = () => {
|
|
344
|
+
if (
|
|
345
|
+
!effectiveHref ||
|
|
346
|
+
newTab ||
|
|
347
|
+
link.external ||
|
|
348
|
+
isDisabled ||
|
|
349
|
+
isResolvingHref ||
|
|
350
|
+
hasSubMenu ||
|
|
351
|
+
archivedItems.length > 0 ||
|
|
352
|
+
!getComparablePath(effectiveHref)
|
|
353
|
+
) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (prefetchedHrefRef.current === effectiveHref) return;
|
|
358
|
+
|
|
359
|
+
prefetchedHrefRef.current = effectiveHref;
|
|
360
|
+
router.prefetch(effectiveHref);
|
|
361
|
+
};
|
|
362
|
+
|
|
341
363
|
const commonProps = {
|
|
342
364
|
className: cn(
|
|
343
365
|
'group/navlink flex w-full cursor-pointer items-center justify-between rounded-md p-2 font-medium text-sm',
|
|
@@ -388,6 +410,8 @@ export function NavLink({
|
|
|
388
410
|
onClick();
|
|
389
411
|
}
|
|
390
412
|
},
|
|
413
|
+
onFocus: prefetchOnIntent,
|
|
414
|
+
onMouseEnter: prefetchOnIntent,
|
|
391
415
|
};
|
|
392
416
|
|
|
393
417
|
const linkElement =
|
|
@@ -425,6 +449,7 @@ export function NavLink({
|
|
|
425
449
|
) : effectiveHref && !isDisabled ? (
|
|
426
450
|
<Link
|
|
427
451
|
href={effectiveHref}
|
|
452
|
+
prefetch={false}
|
|
428
453
|
{...commonProps}
|
|
429
454
|
target={newTab ? '_blank' : '_self'}
|
|
430
455
|
>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { PlanDetailsPollContent } from './poll-display';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
addPollOption: vi.fn(),
|
|
7
|
+
createPoll: vi.fn(),
|
|
8
|
+
deletePoll: vi.fn(),
|
|
9
|
+
deletePollOption: vi.fn(),
|
|
10
|
+
refresh: vi.fn(),
|
|
11
|
+
submitVote: vi.fn(),
|
|
12
|
+
toast: vi.fn(),
|
|
13
|
+
toggleWherePoll: vi.fn(),
|
|
14
|
+
useTimeBlocking: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('@tuturuuu/apis/meet/actions', () => ({
|
|
18
|
+
addPollOption: (...args: unknown[]) => mocks.addPollOption(...args),
|
|
19
|
+
createPoll: (...args: unknown[]) => mocks.createPoll(...args),
|
|
20
|
+
deletePoll: (...args: unknown[]) => mocks.deletePoll(...args),
|
|
21
|
+
deletePollOption: (...args: unknown[]) => mocks.deletePollOption(...args),
|
|
22
|
+
submitVote: (...args: unknown[]) => mocks.submitVote(...args),
|
|
23
|
+
toggleWherePoll: (...args: unknown[]) => mocks.toggleWherePoll(...args),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('@tuturuuu/ui/hooks/time-blocking-provider', () => ({
|
|
27
|
+
useTimeBlocking: () => mocks.useTimeBlocking(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@tuturuuu/ui/hooks/use-toast', () => ({
|
|
31
|
+
toast: (...args: unknown[]) => mocks.toast(...args),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('next/navigation', () => ({
|
|
35
|
+
useRouter: () => ({ refresh: mocks.refresh }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock('next-intl', () => ({
|
|
39
|
+
useTranslations: () => (key: string) => key,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('./where-tu-meet', () => ({
|
|
43
|
+
DefaultWherePollContent: ({
|
|
44
|
+
onAddOption,
|
|
45
|
+
onVote,
|
|
46
|
+
}: {
|
|
47
|
+
onAddOption: (pollId: string, value: string) => Promise<unknown>;
|
|
48
|
+
onVote: (pollId: string, optionIds: string[]) => Promise<void>;
|
|
49
|
+
}) => (
|
|
50
|
+
<div>
|
|
51
|
+
<button type="button" onClick={() => onAddOption('poll-1', 'Cafe')}>
|
|
52
|
+
add guest option
|
|
53
|
+
</button>
|
|
54
|
+
<button type="button" onClick={() => onVote('poll-1', ['option-1'])}>
|
|
55
|
+
submit guest vote
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
describe('PlanDetailsPollContent guest authorization', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
mocks.useTimeBlocking.mockReturnValue({
|
|
65
|
+
user: {
|
|
66
|
+
display_name: 'Guest',
|
|
67
|
+
id: 'guest-1',
|
|
68
|
+
is_guest: true,
|
|
69
|
+
password_hash: 'plan-bound-credential',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
mocks.addPollOption.mockResolvedValue({
|
|
73
|
+
data: {
|
|
74
|
+
option: {
|
|
75
|
+
created_at: '2026-08-10T00:00:00.000Z',
|
|
76
|
+
guestVotes: [],
|
|
77
|
+
id: 'option-1',
|
|
78
|
+
poll_id: 'poll-1',
|
|
79
|
+
totalVotes: 0,
|
|
80
|
+
userVotes: [],
|
|
81
|
+
value: 'Cafe',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
mocks.submitVote.mockResolvedValue({ data: { success: true } });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('forwards the selected guest credential to option and vote actions', async () => {
|
|
89
|
+
render(
|
|
90
|
+
<PlanDetailsPollContent
|
|
91
|
+
plan={{ id: 'plan-1', is_confirmed: false, where_to_meet: true }}
|
|
92
|
+
isCreator={false}
|
|
93
|
+
platformUser={null}
|
|
94
|
+
polls={null}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
fireEvent.click(screen.getByRole('button', { name: 'add guest option' }));
|
|
99
|
+
fireEvent.click(screen.getByRole('button', { name: 'submit guest vote' }));
|
|
100
|
+
|
|
101
|
+
await waitFor(() => {
|
|
102
|
+
expect(mocks.addPollOption).toHaveBeenCalledWith('plan-1', {
|
|
103
|
+
guestId: 'guest-1',
|
|
104
|
+
guestPasswordHash: 'plan-bound-credential',
|
|
105
|
+
pollId: 'poll-1',
|
|
106
|
+
userType: 'GUEST',
|
|
107
|
+
value: 'Cafe',
|
|
108
|
+
});
|
|
109
|
+
expect(mocks.submitVote).toHaveBeenCalledWith('plan-1', {
|
|
110
|
+
guestId: 'guest-1',
|
|
111
|
+
guestPasswordHash: 'plan-bound-credential',
|
|
112
|
+
optionIds: ['option-1'],
|
|
113
|
+
pollId: 'poll-1',
|
|
114
|
+
userType: 'GUEST',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -94,6 +94,10 @@ export function PlanDetailsPollContent({
|
|
|
94
94
|
optionIds,
|
|
95
95
|
userType: userType as 'PLATFORM' | 'GUEST',
|
|
96
96
|
guestId: userType === 'GUEST' ? (user?.id ?? undefined) : undefined,
|
|
97
|
+
guestPasswordHash:
|
|
98
|
+
userType === 'GUEST' && guestUser?.is_guest
|
|
99
|
+
? guestUser.password_hash
|
|
100
|
+
: undefined,
|
|
97
101
|
});
|
|
98
102
|
if (result.error) {
|
|
99
103
|
toast({
|
|
@@ -111,6 +115,10 @@ export function PlanDetailsPollContent({
|
|
|
111
115
|
value,
|
|
112
116
|
userType: userType as 'PLATFORM' | 'GUEST',
|
|
113
117
|
guestId: userType === 'GUEST' ? (user?.id ?? undefined) : undefined,
|
|
118
|
+
guestPasswordHash:
|
|
119
|
+
userType === 'GUEST' && guestUser?.is_guest
|
|
120
|
+
? guestUser.password_hash
|
|
121
|
+
: undefined,
|
|
114
122
|
});
|
|
115
123
|
if (result.error) {
|
|
116
124
|
toast({
|
|
@@ -1,200 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Calendar Settings Resolver
|
|
3
|
-
*
|
|
4
|
-
* Implements priority system for calendar preferences:
|
|
5
|
-
* User settings > Workspace settings > Auto-detection
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
type FirstDayOfWeek = 'auto' | 'sunday' | 'monday' | 'saturday';
|
|
9
|
-
|
|
10
|
-
interface CalendarSettings {
|
|
11
|
-
timezone: string;
|
|
12
|
-
firstDayOfWeek: FirstDayOfWeek;
|
|
13
|
-
timeFormat: '12h' | '24h';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface CalendarUserSettings {
|
|
17
|
-
timezone?: string | null;
|
|
18
|
-
first_day_of_week?: string | null;
|
|
19
|
-
time_format?: string | null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface CalendarWorkspaceSettings {
|
|
23
|
-
timezone?: string | null;
|
|
24
|
-
first_day_of_week?: string | null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Detects the system timezone using Intl API
|
|
29
|
-
*/
|
|
30
|
-
export function detectSystemTimezone(): string {
|
|
31
|
-
try {
|
|
32
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
33
|
-
} catch {
|
|
34
|
-
// Fallback to UTC if detection fails
|
|
35
|
-
return 'UTC';
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Detects the first day of week based on locale
|
|
41
|
-
* Vietnamese: Monday
|
|
42
|
-
* US/Canada: Sunday
|
|
43
|
-
* Middle East: Saturday
|
|
44
|
-
*/
|
|
45
|
-
export function detectLocaleFirstDay(locale?: string): FirstDayOfWeek {
|
|
46
|
-
const userLocale =
|
|
47
|
-
locale || (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
|
|
48
|
-
|
|
49
|
-
// Vietnamese locale uses Monday as first day
|
|
50
|
-
if (userLocale.startsWith('vi')) {
|
|
51
|
-
return 'monday';
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// US, Canada, and some other countries use Sunday
|
|
55
|
-
if (userLocale.startsWith('en-US') || userLocale.startsWith('en-CA')) {
|
|
56
|
-
return 'sunday';
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Middle Eastern countries typically use Saturday
|
|
60
|
-
if (userLocale.startsWith('ar') || userLocale.startsWith('he')) {
|
|
61
|
-
return 'saturday';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Most European and other countries use Monday
|
|
65
|
-
return 'monday';
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Detects the preferred time format based on locale
|
|
70
|
-
* Uses Intl.DateTimeFormat to determine if the locale uses 12h or 24h format
|
|
71
|
-
*/
|
|
72
|
-
export function detectLocaleTimeFormat(locale?: string): '12h' | '24h' {
|
|
73
|
-
const userLocale =
|
|
74
|
-
locale || (typeof navigator !== 'undefined' ? navigator.language : 'en-US');
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
// Use Intl.DateTimeFormat to detect the locale's preferred format
|
|
78
|
-
const formatter = new Intl.DateTimeFormat(userLocale, { hour: 'numeric' });
|
|
79
|
-
const parts = formatter.formatToParts(new Date(2000, 0, 1, 13, 0));
|
|
80
|
-
const hourPart = parts.find((p) => p.type === 'hour');
|
|
81
|
-
|
|
82
|
-
// If hour is "1" instead of "13", it's 12-hour format
|
|
83
|
-
return hourPart?.value === '1' ? '12h' : '24h';
|
|
84
|
-
} catch {
|
|
85
|
-
// Fallback to 12h for English locales, 24h for others
|
|
86
|
-
return userLocale.startsWith('en') ? '12h' : '24h';
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Resolves the effective time format based on user setting
|
|
92
|
-
*/
|
|
93
|
-
export function resolveTimeFormat(
|
|
94
|
-
user?: Pick<CalendarUserSettings, 'time_format'> | null,
|
|
95
|
-
locale?: string
|
|
96
|
-
): '12h' | '24h' {
|
|
97
|
-
// User setting takes priority
|
|
98
|
-
if (user?.time_format && user.time_format !== 'auto') {
|
|
99
|
-
return user.time_format as '12h' | '24h';
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Auto-detection based on locale
|
|
103
|
-
return detectLocaleTimeFormat(locale);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Resolves the effective timezone based on priority system
|
|
108
|
-
*/
|
|
109
|
-
export function resolveTimezone(
|
|
110
|
-
user?: Pick<CalendarUserSettings, 'timezone'> | null,
|
|
111
|
-
workspace?: Pick<CalendarWorkspaceSettings, 'timezone'> | null
|
|
112
|
-
): string {
|
|
113
|
-
// Priority 1: User setting
|
|
114
|
-
if (user?.timezone && user.timezone !== 'auto') {
|
|
115
|
-
return user.timezone;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Priority 2: Workspace setting
|
|
119
|
-
if (workspace?.timezone && workspace.timezone !== 'auto') {
|
|
120
|
-
return workspace.timezone;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Priority 3: Auto-detection
|
|
124
|
-
return detectSystemTimezone();
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Resolves the effective first day of week based on priority system
|
|
129
|
-
*/
|
|
130
|
-
export function resolveFirstDayOfWeek(
|
|
131
|
-
user?: Pick<CalendarUserSettings, 'first_day_of_week'> | null,
|
|
132
|
-
workspace?: Pick<CalendarWorkspaceSettings, 'first_day_of_week'> | null,
|
|
133
|
-
locale?: string
|
|
134
|
-
): FirstDayOfWeek {
|
|
135
|
-
// Priority 1: User setting
|
|
136
|
-
if (user?.first_day_of_week && user.first_day_of_week !== 'auto') {
|
|
137
|
-
return user.first_day_of_week as FirstDayOfWeek;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Priority 2: Workspace setting
|
|
141
|
-
if (workspace?.first_day_of_week && workspace.first_day_of_week !== 'auto') {
|
|
142
|
-
return workspace.first_day_of_week as FirstDayOfWeek;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Priority 3: Auto-detection based on locale
|
|
146
|
-
return detectLocaleFirstDay(locale);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Resolves all calendar settings at once
|
|
151
|
-
*/
|
|
152
|
-
export function resolveCalendarSettings(
|
|
153
|
-
user?: CalendarUserSettings | null,
|
|
154
|
-
workspace?: CalendarWorkspaceSettings | null,
|
|
155
|
-
locale?: string
|
|
156
|
-
): CalendarSettings {
|
|
157
|
-
return {
|
|
158
|
-
timezone: resolveTimezone(user, workspace),
|
|
159
|
-
firstDayOfWeek: resolveFirstDayOfWeek(user, workspace, locale),
|
|
160
|
-
timeFormat: resolveTimeFormat(user, locale),
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Converts first day of week string to number (for Date compatibility)
|
|
166
|
-
* 0 = Sunday, 1 = Monday, 6 = Saturday
|
|
167
|
-
*/
|
|
168
|
-
export function firstDayToNumber(day: FirstDayOfWeek, locale?: string): number {
|
|
169
|
-
if (day === 'auto') {
|
|
170
|
-
day = detectLocaleFirstDay(locale);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
switch (day) {
|
|
174
|
-
case 'sunday':
|
|
175
|
-
return 0;
|
|
176
|
-
case 'monday':
|
|
177
|
-
return 1;
|
|
178
|
-
case 'saturday':
|
|
179
|
-
return 6;
|
|
180
|
-
default:
|
|
181
|
-
return 1; // Default to Monday
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Converts number to first day of week string
|
|
187
|
-
* 0 = Sunday, 1 = Monday, 6 = Saturday
|
|
188
|
-
*/
|
|
189
|
-
export function numberToFirstDay(num: number): FirstDayOfWeek {
|
|
190
|
-
switch (num) {
|
|
191
|
-
case 0:
|
|
192
|
-
return 'sunday';
|
|
193
|
-
case 1:
|
|
194
|
-
return 'monday';
|
|
195
|
-
case 6:
|
|
196
|
-
return 'saturday';
|
|
197
|
-
default:
|
|
198
|
-
return 'monday'; // Default to Monday
|
|
199
|
-
}
|
|
200
|
-
}
|
|
1
|
+
export * from '@tuturuuu/utils/calendar-settings-resolver';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const repoRoot = resolve(
|
|
9
|
+
process.cwd(),
|
|
10
|
+
process.cwd().endsWith('/packages/ui') ? '../..' : '.'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
function readRepoFile(path: string) {
|
|
14
|
+
return readFileSync(resolve(repoRoot, path), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('@tuturuuu/ui public quickstart', () => {
|
|
18
|
+
it('documents only executable public package entry points', () => {
|
|
19
|
+
const readme = readRepoFile('packages/ui/README.md');
|
|
20
|
+
const packageJson = JSON.parse(
|
|
21
|
+
readRepoFile('packages/ui/package.json')
|
|
22
|
+
) as {
|
|
23
|
+
exports: Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
expect(
|
|
27
|
+
readme.match(/import '@tuturuuu\/ui\/globals\.css';/gu) ?? []
|
|
28
|
+
).toHaveLength(1);
|
|
29
|
+
expect(readme).toContain("import { Button } from '@tuturuuu/ui/button';");
|
|
30
|
+
expect(readme).toMatch(
|
|
31
|
+
/import\s*\{[\s\S]*?Card[\s\S]*?CardContent[\s\S]*?CardHeader[\s\S]*?CardTitle[\s\S]*?\}\s*from '@tuturuuu\/ui\/card';/u
|
|
32
|
+
);
|
|
33
|
+
expect(readme).not.toMatch(/from ['"]@tuturuuu\/ui['"]/u);
|
|
34
|
+
expect(readme).not.toContain('variant="primary"');
|
|
35
|
+
expect(packageJson.exports['.']).toBeUndefined();
|
|
36
|
+
expect(packageJson.exports['./button']).toBeDefined();
|
|
37
|
+
expect(packageJson.exports['./*']).toBe('./src/components/ui/*.tsx');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('renders the documented component tree through public subpaths', () => {
|
|
41
|
+
render(
|
|
42
|
+
<Card>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle>Welcome to Tuturuuu</CardTitle>
|
|
45
|
+
</CardHeader>
|
|
46
|
+
<CardContent>
|
|
47
|
+
<Button>Get started</Button>
|
|
48
|
+
</CardContent>
|
|
49
|
+
</Card>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
screen.getByRole('button', { name: 'Get started' })
|
|
54
|
+
).toBeInTheDocument();
|
|
55
|
+
expect(screen.getByText('Welcome to Tuturuuu')).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
});
|