@tuturuuu/ui 0.26.0 → 0.27.0
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 +36 -0
- package/README.md +29 -7
- package/biome.json +1 -1
- package/package.json +48 -54
- package/src/components/ui/calendar-app/hooks/use-calendar-settings.test.ts +32 -0
- package/src/components/ui/custom/combobox.tsx +4 -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/custom/workspace-access/adapters.test.ts +89 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
- package/src/components/ui/custom/workspace-access/types.ts +13 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
- package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
- package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
- 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/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
- package/src/hooks/use-board-actions.test.ts +23 -0
- package/src/hooks/use-board-actions.ts +48 -35
- package/src/hooks/use-calendar-sync.tsx +12 -12
- package/src/hooks/use-notifications.ts +4 -8
- package/src/lib/calendar-settings-resolver.ts +1 -200
- package/src/readme-contract.test.tsx +57 -0
|
@@ -240,7 +240,7 @@ export const CalendarSyncProvider = ({
|
|
|
240
240
|
queryKey: ['workspace-calendars', wsId],
|
|
241
241
|
enabled: !hasExternalEvents && !!wsId,
|
|
242
242
|
queryFn: () => listWorkspaceCalendars(wsId),
|
|
243
|
-
staleTime:
|
|
243
|
+
staleTime: 5 * 60_000,
|
|
244
244
|
});
|
|
245
245
|
const enabledWorkspaceCalendarIds = useMemo(
|
|
246
246
|
() =>
|
|
@@ -438,12 +438,11 @@ export const CalendarSyncProvider = ({
|
|
|
438
438
|
[isVisibleInCurrentRange]
|
|
439
439
|
);
|
|
440
440
|
|
|
441
|
-
// Fetch database events with caching
|
|
442
441
|
const { data: fetchedData, isLoading: isDatabaseLoading } = useQuery({
|
|
443
442
|
queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
|
|
444
443
|
enabled: !hasExternalEvents && !!wsId && dates.length > 0,
|
|
445
|
-
staleTime:
|
|
446
|
-
gcTime:
|
|
444
|
+
staleTime: 2 * 60_000,
|
|
445
|
+
gcTime: 30 * 60_000,
|
|
447
446
|
queryFn: async () => {
|
|
448
447
|
if (!activeCacheKey) return null;
|
|
449
448
|
|
|
@@ -512,10 +511,11 @@ export const CalendarSyncProvider = ({
|
|
|
512
511
|
lastSyncTime: new Date(),
|
|
513
512
|
});
|
|
514
513
|
|
|
515
|
-
|
|
514
|
+
throw err instanceof Error ? err : new Error(errorMessage);
|
|
516
515
|
}
|
|
517
516
|
},
|
|
518
|
-
refetchInterval:
|
|
517
|
+
refetchInterval: 5 * 60_000,
|
|
518
|
+
refetchIntervalInBackground: false,
|
|
519
519
|
});
|
|
520
520
|
|
|
521
521
|
// Legacy direct Google fetch/reconcile is disabled. Provider inbound sync is
|
|
@@ -523,18 +523,17 @@ export const CalendarSyncProvider = ({
|
|
|
523
523
|
const { isLoading: isGoogleLoading } = useQuery({
|
|
524
524
|
queryKey: ['googleCalendarEvents', wsId, activeCacheKey],
|
|
525
525
|
enabled: false,
|
|
526
|
-
staleTime:
|
|
527
|
-
gcTime:
|
|
526
|
+
staleTime: 2 * 60_000,
|
|
527
|
+
gcTime: 30 * 60_000,
|
|
528
528
|
queryFn: async () => null,
|
|
529
|
-
refetchInterval: 60000, // Reduced from 30s to 60s to lower load
|
|
530
529
|
});
|
|
531
530
|
|
|
532
531
|
// Fetch habit calendar events to identify which events are habits
|
|
533
532
|
const { data: habitEventData } = useQuery({
|
|
534
533
|
queryKey: ['habitCalendarEvents', wsId, activeCacheKey],
|
|
535
534
|
enabled: !hasExternalEvents && !!wsId && dates.length > 0,
|
|
536
|
-
staleTime:
|
|
537
|
-
gcTime:
|
|
535
|
+
staleTime: 2 * 60_000,
|
|
536
|
+
gcTime: 30 * 60_000,
|
|
538
537
|
queryFn: async () => {
|
|
539
538
|
const startDate = dayjs(dates[0]).startOf('day');
|
|
540
539
|
const endDate = dayjs(dates[dates.length - 1])
|
|
@@ -571,7 +570,8 @@ export const CalendarSyncProvider = ({
|
|
|
571
570
|
};
|
|
572
571
|
}
|
|
573
572
|
},
|
|
574
|
-
refetchInterval:
|
|
573
|
+
refetchInterval: 5 * 60_000,
|
|
574
|
+
refetchIntervalInBackground: false,
|
|
575
575
|
});
|
|
576
576
|
|
|
577
577
|
// Helper to check if dates have actually changed
|
|
@@ -91,7 +91,8 @@ interface NotificationSubscriptionEntry {
|
|
|
91
91
|
supabase: SupabaseClient;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export const
|
|
94
|
+
export const UNREAD_COUNT_STALE_TIME_MS = 5 * 60 * 1000;
|
|
95
|
+
export const UNREAD_COUNT_FALLBACK_INTERVAL_MS = 15 * 60 * 1000;
|
|
95
96
|
|
|
96
97
|
const notificationSubscriptionRegistry = new Map<
|
|
97
98
|
string,
|
|
@@ -164,12 +165,7 @@ function createNotificationSubscriptionEntry(
|
|
|
164
165
|
table: 'notifications',
|
|
165
166
|
filter: `user_id=eq.${userId}`,
|
|
166
167
|
},
|
|
167
|
-
|
|
168
|
-
const newRecord = payload.new as Notification;
|
|
169
|
-
if (newRecord?.data?.action_taken) {
|
|
170
|
-
invalidateQueries();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
168
|
+
invalidateQueries
|
|
173
169
|
)
|
|
174
170
|
.on(
|
|
175
171
|
'postgres_changes',
|
|
@@ -348,7 +344,7 @@ export function useUnreadCount(
|
|
|
348
344
|
return data.count as number;
|
|
349
345
|
},
|
|
350
346
|
enabled: options?.enabled ?? true,
|
|
351
|
-
staleTime:
|
|
347
|
+
staleTime: UNREAD_COUNT_STALE_TIME_MS,
|
|
352
348
|
// Realtime invalidation is the primary update path. Keep a low-frequency
|
|
353
349
|
// refresh as a safety net for disconnected or suspended browser sessions.
|
|
354
350
|
refetchInterval: UNREAD_COUNT_FALLBACK_INTERVAL_MS,
|
|
@@ -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
|
+
});
|