@tuturuuu/ui 0.37.0 → 0.37.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 +26 -0
- package/package.json +7 -5
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -0
- package/src/components/ui/custom/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +87 -359
- 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/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- 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/components/ui/resizable.tsx +2 -1
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
- package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -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/hooks/use-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- package/src/lib/calendar-day.ts +67 -0
|
@@ -9,7 +9,6 @@ import type {
|
|
|
9
9
|
} from '@tuturuuu/types';
|
|
10
10
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
11
11
|
import { isAllDayEvent } from '@tuturuuu/utils/calendar-utils';
|
|
12
|
-
import dayjs from 'dayjs';
|
|
13
12
|
import {
|
|
14
13
|
createContext,
|
|
15
14
|
type SetStateAction,
|
|
@@ -21,12 +20,18 @@ import {
|
|
|
21
20
|
useState,
|
|
22
21
|
} from 'react';
|
|
23
22
|
import { toast } from '../components/ui/sonner';
|
|
23
|
+
import { calendarQueryRange } from '../lib/calendar-day';
|
|
24
24
|
import { runCalendarProviderSync } from './calendar-provider-sync';
|
|
25
25
|
import {
|
|
26
26
|
type CacheUpdate,
|
|
27
27
|
type CalendarCache,
|
|
28
28
|
updateCalendarRangeCache,
|
|
29
29
|
} from './calendar-range-cache';
|
|
30
|
+
import {
|
|
31
|
+
calendarEventInQueryRange,
|
|
32
|
+
calendarRangeCacheKey,
|
|
33
|
+
calendarRangeIncludesToday,
|
|
34
|
+
} from './calendar-sync-range';
|
|
30
35
|
|
|
31
36
|
// Type for calendar connection
|
|
32
37
|
type CalendarConnection = {
|
|
@@ -91,6 +96,8 @@ const CalendarSyncContext = createContext<{
|
|
|
91
96
|
googleData: WorkspaceCalendarEvent[] | null;
|
|
92
97
|
error: Error | null;
|
|
93
98
|
dates: Date[];
|
|
99
|
+
timezone: string | undefined;
|
|
100
|
+
setTimezone: (timezone: string | undefined) => void;
|
|
94
101
|
|
|
95
102
|
setDates: (dates: Date[]) => void;
|
|
96
103
|
currentView: 'day' | '4-day' | 'week' | 'month';
|
|
@@ -141,6 +148,8 @@ const CalendarSyncContext = createContext<{
|
|
|
141
148
|
googleData: null,
|
|
142
149
|
error: null,
|
|
143
150
|
dates: [],
|
|
151
|
+
timezone: undefined,
|
|
152
|
+
setTimezone: () => {},
|
|
144
153
|
setDates: () => {},
|
|
145
154
|
currentView: 'day',
|
|
146
155
|
setCurrentView: () => {},
|
|
@@ -196,6 +205,7 @@ export const CalendarSyncProvider = ({
|
|
|
196
205
|
|
|
197
206
|
const [error, setError] = useState<Error | null>(null);
|
|
198
207
|
const [dates, setDates] = useState<Date[]>([]);
|
|
208
|
+
const [timezone, setTimezone] = useState<string>();
|
|
199
209
|
const [currentView, setCurrentView] = useState<
|
|
200
210
|
'day' | '4-day' | 'week' | 'month'
|
|
201
211
|
>('day');
|
|
@@ -257,53 +267,18 @@ export const CalendarSyncProvider = ({
|
|
|
257
267
|
|
|
258
268
|
const setCalendarConnections = setCalendarConnectionsState;
|
|
259
269
|
|
|
260
|
-
// Helper to generate cache key from dates
|
|
261
|
-
const getCacheKey = useCallback((dateRange: Date[]) => {
|
|
262
|
-
if (!dateRange || dateRange.length === 0) {
|
|
263
|
-
return '';
|
|
264
|
-
}
|
|
265
|
-
return `${dateRange[0]!.toISOString()}-${dateRange[dateRange.length - 1]!.toISOString()}`;
|
|
266
|
-
}, []);
|
|
267
|
-
|
|
268
270
|
const activeCacheKey = useMemo(
|
|
269
|
-
() =>
|
|
270
|
-
|
|
271
|
+
() =>
|
|
272
|
+
dates.length ? `${wsId}:${calendarRangeCacheKey(dates, timezone)}` : '',
|
|
273
|
+
[dates, timezone, wsId]
|
|
271
274
|
);
|
|
272
275
|
const activeCachedDatabaseEvents = activeCacheKey
|
|
273
276
|
? calendarCache[activeCacheKey]?.dbEvents
|
|
274
277
|
: undefined;
|
|
275
278
|
|
|
276
|
-
// Helper to check if a date range includes today (current week issue)
|
|
277
|
-
const includesCurrentWeek = useCallback((dateRange: Date[]) => {
|
|
278
|
-
if (!dateRange || dateRange.length === 0) return false;
|
|
279
|
-
const today = new Date();
|
|
280
|
-
const startOfToday = new Date(
|
|
281
|
-
today.getFullYear(),
|
|
282
|
-
today.getMonth(),
|
|
283
|
-
today.getDate()
|
|
284
|
-
);
|
|
285
|
-
const endOfToday = new Date(
|
|
286
|
-
today.getFullYear(),
|
|
287
|
-
today.getMonth(),
|
|
288
|
-
today.getDate(),
|
|
289
|
-
23,
|
|
290
|
-
59,
|
|
291
|
-
59
|
|
292
|
-
);
|
|
293
|
-
|
|
294
|
-
const firstDate = dateRange[0];
|
|
295
|
-
const lastDate = dateRange[dateRange.length - 1];
|
|
296
|
-
if (!firstDate || !lastDate) return false;
|
|
297
|
-
|
|
298
|
-
const rangeStart = new Date(firstDate);
|
|
299
|
-
const rangeEnd = new Date(lastDate);
|
|
300
|
-
|
|
301
|
-
return rangeStart <= endOfToday && rangeEnd >= startOfToday;
|
|
302
|
-
}, []);
|
|
303
|
-
|
|
304
279
|
// Enhanced cache staleness check - shorter staleness for current week
|
|
305
280
|
const isCacheStaleEnhanced = (lastUpdated: number, dateRange: Date[]) => {
|
|
306
|
-
const isCurrentWeek =
|
|
281
|
+
const isCurrentWeek = calendarRangeIncludesToday(dateRange, timezone);
|
|
307
282
|
// 30 seconds for current week, 5 minutes for other weeks
|
|
308
283
|
const staleTime = isCurrentWeek ? 30 * 1000 : 5 * 60 * 1000; // 30 seconds
|
|
309
284
|
return Date.now() - lastUpdated >= staleTime;
|
|
@@ -316,27 +291,9 @@ export const CalendarSyncProvider = ({
|
|
|
316
291
|
}, []);
|
|
317
292
|
|
|
318
293
|
const isVisibleInCurrentRange = useCallback(
|
|
319
|
-
(event: { start_at?: string; end_at?: string }) =>
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const firstDate = dates[0];
|
|
323
|
-
const lastDate = dates[dates.length - 1];
|
|
324
|
-
if (!firstDate || !lastDate) return true;
|
|
325
|
-
|
|
326
|
-
if (!event.start_at && !event.end_at) return true;
|
|
327
|
-
|
|
328
|
-
const rangeStart = dayjs(firstDate).startOf('day').valueOf();
|
|
329
|
-
const rangeEnd = dayjs(lastDate).endOf('day').valueOf();
|
|
330
|
-
const startAt = event.start_at ? dayjs(event.start_at).valueOf() : NaN;
|
|
331
|
-
const endAt = event.end_at ? dayjs(event.end_at).valueOf() : startAt;
|
|
332
|
-
|
|
333
|
-
if (Number.isNaN(startAt) || Number.isNaN(endAt)) {
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return startAt <= rangeEnd && endAt >= rangeStart;
|
|
338
|
-
},
|
|
339
|
-
[dates]
|
|
294
|
+
(event: { start_at?: string; end_at?: string }) =>
|
|
295
|
+
calendarEventInQueryRange(event, dates, timezone),
|
|
296
|
+
[dates, timezone]
|
|
340
297
|
);
|
|
341
298
|
|
|
342
299
|
const patchVisibleEvents = useCallback(
|
|
@@ -417,10 +374,10 @@ export const CalendarSyncProvider = ({
|
|
|
417
374
|
}
|
|
418
375
|
|
|
419
376
|
// Otherwise fetch fresh data via API (which handles E2EE decryption)
|
|
420
|
-
const startDate =
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
377
|
+
const { start: startDate, end: endDate } = calendarQueryRange(
|
|
378
|
+
dates,
|
|
379
|
+
timezone
|
|
380
|
+
);
|
|
424
381
|
|
|
425
382
|
try {
|
|
426
383
|
const response = await fetch(
|
|
@@ -494,10 +451,10 @@ export const CalendarSyncProvider = ({
|
|
|
494
451
|
staleTime: 2 * 60_000,
|
|
495
452
|
gcTime: 30 * 60_000,
|
|
496
453
|
queryFn: async () => {
|
|
497
|
-
const startDate =
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
454
|
+
const { start: startDate, end: endDate } = calendarQueryRange(
|
|
455
|
+
dates,
|
|
456
|
+
timezone
|
|
457
|
+
);
|
|
501
458
|
|
|
502
459
|
try {
|
|
503
460
|
const response = await fetch(
|
|
@@ -534,15 +491,18 @@ export const CalendarSyncProvider = ({
|
|
|
534
491
|
});
|
|
535
492
|
|
|
536
493
|
// Helper to check if dates have actually changed
|
|
537
|
-
const areDatesEqual = useCallback(
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
494
|
+
const areDatesEqual = useCallback(
|
|
495
|
+
(newDates: Date[]) => {
|
|
496
|
+
const newDatesStr = calendarRangeCacheKey(newDates, timezone);
|
|
497
|
+
const prevDatesStr = prevDatesRef.current;
|
|
498
|
+
const areEqual = newDatesStr === prevDatesStr;
|
|
499
|
+
if (!areEqual) {
|
|
500
|
+
prevDatesRef.current = newDatesStr;
|
|
501
|
+
}
|
|
502
|
+
return areEqual;
|
|
503
|
+
},
|
|
504
|
+
[timezone]
|
|
505
|
+
);
|
|
546
506
|
|
|
547
507
|
// Invalidate and refetch events
|
|
548
508
|
const refresh = useCallback(() => {
|
|
@@ -654,7 +614,7 @@ export const CalendarSyncProvider = ({
|
|
|
654
614
|
const cacheData = calendarCache[activeCacheKey];
|
|
655
615
|
|
|
656
616
|
// For current week, force a fresh database fetch
|
|
657
|
-
const isCurrentWeek =
|
|
617
|
+
const isCurrentWeek = calendarRangeIncludesToday(dates, timezone);
|
|
658
618
|
|
|
659
619
|
if (cacheData && isCurrentWeek) {
|
|
660
620
|
isForcedRef.current = true;
|
|
@@ -666,7 +626,7 @@ export const CalendarSyncProvider = ({
|
|
|
666
626
|
}, [
|
|
667
627
|
dates,
|
|
668
628
|
calendarCache,
|
|
669
|
-
|
|
629
|
+
timezone,
|
|
670
630
|
areDatesEqual,
|
|
671
631
|
activeCacheKey,
|
|
672
632
|
updateCache,
|
|
@@ -841,6 +801,8 @@ export const CalendarSyncProvider = ({
|
|
|
841
801
|
error,
|
|
842
802
|
dates,
|
|
843
803
|
setDates,
|
|
804
|
+
timezone,
|
|
805
|
+
setTimezone,
|
|
844
806
|
currentView,
|
|
845
807
|
setCurrentView,
|
|
846
808
|
syncToTuturuuu,
|
|
@@ -437,7 +437,7 @@ export const CalendarProvider = ({
|
|
|
437
437
|
const updateQueueRef = useRef<PendingEventUpdate[]>([]);
|
|
438
438
|
const isProcessingQueueRef = useRef<boolean>(false);
|
|
439
439
|
|
|
440
|
-
const { events, refresh, patchVisibleEvents } = useCalendarSync();
|
|
440
|
+
const { events, refresh, patchVisibleEvents, timezone } = useCalendarSync();
|
|
441
441
|
|
|
442
442
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
|
443
443
|
const [previewEventId, setPreviewEventId] = useState<string | null>(null);
|
|
@@ -503,8 +503,8 @@ export const CalendarProvider = ({
|
|
|
503
503
|
);
|
|
504
504
|
|
|
505
505
|
const getCurrentEvents = useMemo(
|
|
506
|
-
() => createCalendarEventLookup(events),
|
|
507
|
-
[events]
|
|
506
|
+
() => createCalendarEventLookup(events, timezone),
|
|
507
|
+
[events, timezone]
|
|
508
508
|
);
|
|
509
509
|
|
|
510
510
|
const getUpcomingEvent = useCallback(() => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useQuery } from '@tanstack/react-query';
|
|
4
|
+
import { listWorkspaces } from '@tuturuuu/internal-api/workspaces';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceActor,
|
|
7
|
+
useWorkspaceVisibility,
|
|
8
|
+
} from './use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
/** UI choices only. Do not use this hook for access or membership reconciliation. */
|
|
11
|
+
export function useVisibleWorkspaces(
|
|
12
|
+
enabled = true,
|
|
13
|
+
limit?: number,
|
|
14
|
+
options?: { refetchInterval?: number }
|
|
15
|
+
) {
|
|
16
|
+
const actor = useWorkspaceActor();
|
|
17
|
+
const visibility = useWorkspaceVisibility(enabled);
|
|
18
|
+
const query = useQuery({
|
|
19
|
+
queryKey: [
|
|
20
|
+
'workspace-ui-list',
|
|
21
|
+
actor?.actorId,
|
|
22
|
+
...(limit !== undefined ? [limit] : []),
|
|
23
|
+
],
|
|
24
|
+
enabled: enabled && Boolean(actor),
|
|
25
|
+
refetchInterval: options?.refetchInterval,
|
|
26
|
+
queryFn: async () => {
|
|
27
|
+
actor!.assertActive();
|
|
28
|
+
const result = await listWorkspaces(
|
|
29
|
+
limit !== undefined ? { limit } : undefined
|
|
30
|
+
);
|
|
31
|
+
actor!.assertActive();
|
|
32
|
+
return result;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const data = visibility.known
|
|
36
|
+
? query.data?.filter(
|
|
37
|
+
(workspace) => !visibility.hiddenIds.includes(workspace.id)
|
|
38
|
+
)
|
|
39
|
+
: undefined;
|
|
40
|
+
return {
|
|
41
|
+
...query,
|
|
42
|
+
personalWorkspaceMissing:
|
|
43
|
+
visibility.known &&
|
|
44
|
+
query.data !== undefined &&
|
|
45
|
+
!query.data.some((workspace) => workspace.personal),
|
|
46
|
+
data,
|
|
47
|
+
isLoading:
|
|
48
|
+
enabled &&
|
|
49
|
+
(query.isLoading || (!visibility.known && !visibility.isError)),
|
|
50
|
+
isError: query.isError || visibility.isError,
|
|
51
|
+
error: query.error ?? visibility.error,
|
|
52
|
+
refetch: async () => {
|
|
53
|
+
const visibilityResult = await visibility.refetch();
|
|
54
|
+
return visibilityResult ? query.refetch() : null;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useQuery } from '@tanstack/react-query';
|
|
4
4
|
import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
|
|
5
5
|
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
6
|
+
import { useWorkspaceActor } from './use-workspace-visibility';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Hook to fetch the current workspace user with their private details
|
|
@@ -10,11 +11,18 @@ import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
|
10
11
|
*
|
|
11
12
|
* @returns Query object with user data, loading state, and error info
|
|
12
13
|
*/
|
|
13
|
-
export function useWorkspaceUser() {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
export function useWorkspaceUser(actorId?: string) {
|
|
15
|
+
const actor = useWorkspaceActor();
|
|
16
|
+
const resolvedActorId = actorId ?? actor?.actorId;
|
|
17
|
+
const query = useQuery({
|
|
18
|
+
queryKey: ['workspace-user', resolvedActorId],
|
|
19
|
+
enabled: Boolean(actor) && actor?.actorId === resolvedActorId,
|
|
16
20
|
queryFn: async (): Promise<WorkspaceUser> => {
|
|
21
|
+
actor!.assertActive();
|
|
17
22
|
const data = await getCurrentUserProfile();
|
|
23
|
+
actor!.assertActive();
|
|
24
|
+
if (data.id !== resolvedActorId)
|
|
25
|
+
throw new Error('Workspace account changed');
|
|
18
26
|
return {
|
|
19
27
|
id: data.id,
|
|
20
28
|
email: data.email,
|
|
@@ -30,4 +38,8 @@ export function useWorkspaceUser() {
|
|
|
30
38
|
gcTime: 10 * 60 * 1000, // Keep cached data for 10 minutes
|
|
31
39
|
retry: 1, // Retry once on failure
|
|
32
40
|
});
|
|
41
|
+
return {
|
|
42
|
+
...query,
|
|
43
|
+
data: actor?.actorId === resolvedActorId ? query.data : undefined,
|
|
44
|
+
};
|
|
33
45
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type QueryClient,
|
|
5
|
+
useQuery,
|
|
6
|
+
useQueryClient,
|
|
7
|
+
} from '@tanstack/react-query';
|
|
8
|
+
import { InternalApiError } from '@tuturuuu/internal-api/client';
|
|
9
|
+
import {
|
|
10
|
+
getCurrentUserHiddenWorkspaces,
|
|
11
|
+
updateCurrentUserHiddenWorkspace,
|
|
12
|
+
} from '@tuturuuu/internal-api/users';
|
|
13
|
+
import {
|
|
14
|
+
createContext,
|
|
15
|
+
createElement,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
useContext,
|
|
18
|
+
useEffect,
|
|
19
|
+
useMemo,
|
|
20
|
+
useRef,
|
|
21
|
+
useState,
|
|
22
|
+
} from 'react';
|
|
23
|
+
|
|
24
|
+
const controls = new WeakMap<
|
|
25
|
+
QueryClient,
|
|
26
|
+
Map<string, { revision: number; pending: Set<string> }>
|
|
27
|
+
>();
|
|
28
|
+
function ownerControl(client: QueryClient, actorId: string) {
|
|
29
|
+
let actors = controls.get(client);
|
|
30
|
+
if (!actors) {
|
|
31
|
+
actors = new Map();
|
|
32
|
+
controls.set(client, actors);
|
|
33
|
+
}
|
|
34
|
+
let control = actors.get(actorId);
|
|
35
|
+
if (!control) {
|
|
36
|
+
control = { revision: 0, pending: new Set() };
|
|
37
|
+
actors.set(actorId, control);
|
|
38
|
+
}
|
|
39
|
+
return control;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type ActorScope = {
|
|
43
|
+
actorId: string;
|
|
44
|
+
lifetime: { active: boolean };
|
|
45
|
+
assertActive: () => void;
|
|
46
|
+
};
|
|
47
|
+
const ActorContext = createContext<ActorScope | null>(null);
|
|
48
|
+
|
|
49
|
+
/** Feed only a server-verified actor; preferences never become membership data. */
|
|
50
|
+
export function WorkspaceVisibilityProvider({
|
|
51
|
+
actorId,
|
|
52
|
+
children,
|
|
53
|
+
}: {
|
|
54
|
+
actorId: string;
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
}) {
|
|
57
|
+
const parent = useContext(ActorContext);
|
|
58
|
+
const current = useRef(actorId);
|
|
59
|
+
current.current = actorId;
|
|
60
|
+
const scope = useMemo(() => {
|
|
61
|
+
if (parent?.actorId === actorId) return parent;
|
|
62
|
+
const lifetime = { active: true };
|
|
63
|
+
return {
|
|
64
|
+
actorId,
|
|
65
|
+
lifetime,
|
|
66
|
+
assertActive() {
|
|
67
|
+
parent?.assertActive();
|
|
68
|
+
if (
|
|
69
|
+
!lifetime.active ||
|
|
70
|
+
current.current !== actorId ||
|
|
71
|
+
(parent && parent.actorId !== actorId)
|
|
72
|
+
) {
|
|
73
|
+
throw new Error('Workspace account changed');
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}, [actorId, parent]);
|
|
78
|
+
const client = useQueryClient();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (scope === parent) return;
|
|
81
|
+
scope.lifetime.active = true;
|
|
82
|
+
return () => {
|
|
83
|
+
scope.lifetime.active = false;
|
|
84
|
+
controls.get(client)?.delete(scope.actorId);
|
|
85
|
+
// A private cache is never retained across logout/account replacement.
|
|
86
|
+
client.removeQueries({ queryKey: ['workspace-hidden', scope.actorId] });
|
|
87
|
+
client.removeQueries({ queryKey: ['workspace-ui-list', scope.actorId] });
|
|
88
|
+
client.removeQueries({ queryKey: ['user-workspaces', scope.actorId] });
|
|
89
|
+
client.removeQueries({ queryKey: ['all-user-boards', scope.actorId] });
|
|
90
|
+
client.removeQueries({ queryKey: ['chat-workspaces', scope.actorId] });
|
|
91
|
+
client.removeQueries({ queryKey: ['task-source-boards', scope.actorId] });
|
|
92
|
+
client.removeQueries({ queryKey: ['workspace-user', scope.actorId] });
|
|
93
|
+
client.removeQueries({
|
|
94
|
+
queryKey: ['workspace-invitations', scope.actorId],
|
|
95
|
+
});
|
|
96
|
+
client.removeQueries({
|
|
97
|
+
predicate: (query) =>
|
|
98
|
+
query.queryKey[0] === 'global-command-launcher' &&
|
|
99
|
+
query.queryKey.includes(scope.actorId),
|
|
100
|
+
});
|
|
101
|
+
client.removeQueries({
|
|
102
|
+
queryKey: ['workspace-select-current-workspace', scope.actorId],
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
}, [scope, parent, client]);
|
|
106
|
+
return createElement(
|
|
107
|
+
ActorContext.Provider,
|
|
108
|
+
{
|
|
109
|
+
key: actorId,
|
|
110
|
+
value: parent && parent.actorId !== actorId ? null : scope,
|
|
111
|
+
},
|
|
112
|
+
children
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useWorkspaceActor() {
|
|
117
|
+
return useContext(ActorContext);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function useWorkspaceVisibility(enabled = true) {
|
|
121
|
+
const scope = useWorkspaceActor();
|
|
122
|
+
const actorId = scope?.actorId;
|
|
123
|
+
const client = useQueryClient();
|
|
124
|
+
const key = ['workspace-hidden', actorId] as const;
|
|
125
|
+
const control = ownerControl(client, actorId ?? 'signed-out');
|
|
126
|
+
const [pending, setPending] = useState<Set<string>>(new Set());
|
|
127
|
+
const [updateError, setUpdateError] = useState<Error | null>(null);
|
|
128
|
+
const query = useQuery({
|
|
129
|
+
queryKey: key,
|
|
130
|
+
enabled: enabled && Boolean(scope),
|
|
131
|
+
queryFn: async () => {
|
|
132
|
+
scope!.assertActive();
|
|
133
|
+
if (control.pending.size > 0) {
|
|
134
|
+
const optimistic = client.getQueryData<string[]>(key);
|
|
135
|
+
if (optimistic) return optimistic;
|
|
136
|
+
}
|
|
137
|
+
const requestRevision = control.revision;
|
|
138
|
+
const response = await getCurrentUserHiddenWorkspaces(actorId!);
|
|
139
|
+
scope!.assertActive();
|
|
140
|
+
// A refresh started before a mutation cannot overwrite its optimistic state.
|
|
141
|
+
if (requestRevision !== control.revision) {
|
|
142
|
+
return (
|
|
143
|
+
client.getQueryData<string[]>(key) ?? response.hiddenWorkspaceIds
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return response.hiddenWorkspaceIds;
|
|
147
|
+
},
|
|
148
|
+
staleTime: 30_000,
|
|
149
|
+
retry: 1,
|
|
150
|
+
});
|
|
151
|
+
const accountDenied =
|
|
152
|
+
query.error instanceof InternalApiError &&
|
|
153
|
+
[401, 403, 409].includes(query.error.status);
|
|
154
|
+
const ids = accountDenied ? undefined : query.data;
|
|
155
|
+
async function setHidden(workspaceId: string, hidden: boolean) {
|
|
156
|
+
if (!scope || !ids || control.pending.has(workspaceId)) return;
|
|
157
|
+
scope.assertActive();
|
|
158
|
+
const ownerKey = ['workspace-hidden', scope.actorId] as const;
|
|
159
|
+
const wasHidden = ids.includes(workspaceId);
|
|
160
|
+
control.pending.add(workspaceId);
|
|
161
|
+
setPending(new Set(control.pending));
|
|
162
|
+
setUpdateError(null);
|
|
163
|
+
control.revision++;
|
|
164
|
+
await client.cancelQueries({ queryKey: ownerKey });
|
|
165
|
+
scope.assertActive();
|
|
166
|
+
client.setQueryData<string[]>(ownerKey, (current = []) =>
|
|
167
|
+
hidden
|
|
168
|
+
? [...new Set([...current, workspaceId])]
|
|
169
|
+
: current.filter((id) => id !== workspaceId)
|
|
170
|
+
);
|
|
171
|
+
try {
|
|
172
|
+
await updateCurrentUserHiddenWorkspace(
|
|
173
|
+
workspaceId,
|
|
174
|
+
hidden,
|
|
175
|
+
scope.actorId
|
|
176
|
+
);
|
|
177
|
+
scope.assertActive();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try {
|
|
180
|
+
scope.assertActive();
|
|
181
|
+
} catch {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
client.setQueryData<string[]>(ownerKey, (current = []) =>
|
|
185
|
+
wasHidden
|
|
186
|
+
? [...new Set([...current, workspaceId])]
|
|
187
|
+
: current.filter((id) => id !== workspaceId)
|
|
188
|
+
);
|
|
189
|
+
setUpdateError(
|
|
190
|
+
error instanceof Error
|
|
191
|
+
? error
|
|
192
|
+
: new Error('Unable to update Hidden workspaces')
|
|
193
|
+
);
|
|
194
|
+
throw error;
|
|
195
|
+
} finally {
|
|
196
|
+
control.pending.delete(workspaceId);
|
|
197
|
+
try {
|
|
198
|
+
scope.assertActive();
|
|
199
|
+
setPending(new Set(control.pending));
|
|
200
|
+
if (control.pending.size === 0) {
|
|
201
|
+
void client.invalidateQueries({ queryKey: ownerKey });
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
/* An old account cannot publish into the next account. */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
...query,
|
|
210
|
+
refetch: async () => {
|
|
211
|
+
if (!scope) return null;
|
|
212
|
+
try {
|
|
213
|
+
scope.assertActive();
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const result = await query.refetch();
|
|
218
|
+
try {
|
|
219
|
+
scope.assertActive();
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (!result.error) setUpdateError(null);
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
226
|
+
accountDenied,
|
|
227
|
+
actorId,
|
|
228
|
+
hiddenIds: ids ?? [],
|
|
229
|
+
known: ids !== undefined,
|
|
230
|
+
pending,
|
|
231
|
+
updateError,
|
|
232
|
+
setHidden,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildDateInTimezone,
|
|
3
|
+
getDatePartsInTimezone,
|
|
4
|
+
resolveTaskTimezone,
|
|
5
|
+
} from '@tuturuuu/utils/task-date-timezone';
|
|
6
|
+
|
|
7
|
+
/** Local fields carry a calendar day; this Date is never an event instant. */
|
|
8
|
+
export function calendarDay(date: Date): Date {
|
|
9
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
10
|
+
}
|
|
11
|
+
export function calendarDayKey(date: Date): string {
|
|
12
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
|
13
|
+
}
|
|
14
|
+
export function calendarInstantDay(instant: Date, zone?: string): Date {
|
|
15
|
+
const parts = getDatePartsInTimezone(
|
|
16
|
+
instant,
|
|
17
|
+
resolveTaskTimezone(zone ?? 'auto')
|
|
18
|
+
);
|
|
19
|
+
return new Date(parts.year, parts.month - 1, parts.day);
|
|
20
|
+
}
|
|
21
|
+
export function calendarToday(zone?: string, now = new Date()): Date {
|
|
22
|
+
return calendarInstantDay(now, zone);
|
|
23
|
+
}
|
|
24
|
+
/** Construct each midnight independently so DST days can span 23 or 25 hours. */
|
|
25
|
+
export function calendarDayBoundary(day: Date, zone?: string): Date {
|
|
26
|
+
return buildDateInTimezone(
|
|
27
|
+
day.getFullYear(),
|
|
28
|
+
day.getMonth() + 1,
|
|
29
|
+
day.getDate(),
|
|
30
|
+
0,
|
|
31
|
+
0,
|
|
32
|
+
resolveTaskTimezone(zone ?? 'auto')
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
export function calendarQueryRange(dates: Date[], zone?: string) {
|
|
36
|
+
const first = dates[0];
|
|
37
|
+
const last = dates.at(-1);
|
|
38
|
+
if (!first || !last) throw new Error('Calendar range needs at least one day');
|
|
39
|
+
const afterLast = new Date(
|
|
40
|
+
last.getFullYear(),
|
|
41
|
+
last.getMonth(),
|
|
42
|
+
last.getDate() + 1
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
start: calendarDayBoundary(first, zone),
|
|
46
|
+
end: calendarDayBoundary(afterLast, zone),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Date-only navigation preserves its YMD; timestamp navigation projects an instant. */
|
|
50
|
+
export function calendarNavigationDate(
|
|
51
|
+
value: string,
|
|
52
|
+
zone?: string
|
|
53
|
+
): Date | null {
|
|
54
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
55
|
+
if (match) {
|
|
56
|
+
const date = new Date(
|
|
57
|
+
Number(match[1]),
|
|
58
|
+
Number(match[2]) - 1,
|
|
59
|
+
Number(match[3])
|
|
60
|
+
);
|
|
61
|
+
return calendarDayKey(date) === value ? date : null;
|
|
62
|
+
}
|
|
63
|
+
const instant = new Date(value);
|
|
64
|
+
return Number.isFinite(instant.getTime())
|
|
65
|
+
? calendarInstantDay(instant, zone)
|
|
66
|
+
: null;
|
|
67
|
+
}
|