@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -5
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  6. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  7. package/src/components/ui/custom/structure.tsx +37 -5
  8. package/src/components/ui/custom/user-filters.tsx +2 -1
  9. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  10. package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
  11. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  12. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  13. package/src/components/ui/custom/workspace-select.tsx +87 -359
  14. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  15. package/src/components/ui/date-time-picker-values.ts +205 -0
  16. package/src/components/ui/date-time-picker.tsx +70 -167
  17. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  18. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  19. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  20. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  21. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  22. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  23. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  24. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  25. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  26. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  27. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  28. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  29. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  30. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  31. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  32. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  33. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  34. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  35. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  36. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  37. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  38. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  39. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  40. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  41. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  42. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  43. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  44. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  45. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  46. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  47. package/src/components/ui/resizable.tsx +2 -1
  48. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  49. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  52. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  53. package/src/hooks/calendar-event-lookup.ts +22 -8
  54. package/src/hooks/calendar-sync-range.test.ts +92 -0
  55. package/src/hooks/calendar-sync-range.ts +40 -0
  56. package/src/hooks/use-calendar-clock.ts +49 -0
  57. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  58. package/src/hooks/use-calendar-sync.tsx +42 -80
  59. package/src/hooks/use-calendar.tsx +3 -3
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-user.ts +15 -3
  62. package/src/hooks/use-workspace-visibility.ts +234 -0
  63. 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
- () => (dates.length ? `${wsId}:${getCacheKey(dates)}` : ''),
270
- [dates, getCacheKey, wsId]
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 = includesCurrentWeek(dateRange);
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
- if (dates.length === 0) return true;
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 = dayjs(dates[0]).startOf('day');
421
- const endDate = dayjs(dates[dates.length - 1])
422
- .add(1, 'day')
423
- .startOf('day');
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 = dayjs(dates[0]).startOf('day');
498
- const endDate = dayjs(dates[dates.length - 1])
499
- .add(1, 'day')
500
- .startOf('day');
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((newDates: Date[]) => {
538
- const newDatesStr = JSON.stringify(newDates.map((d) => d.toISOString()));
539
- const prevDatesStr = prevDatesRef.current;
540
- const areEqual = newDatesStr === prevDatesStr;
541
- if (!areEqual) {
542
- prevDatesRef.current = newDatesStr;
543
- }
544
- return areEqual;
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 = includesCurrentWeek(dates);
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
- includesCurrentWeek,
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
- return useQuery({
15
- queryKey: ['workspace-user'],
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
+ }