@tuturuuu/ui 0.38.0 → 0.39.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  4. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  5. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  6. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  7. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  8. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  9. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  10. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  11. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  12. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  13. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  14. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  15. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  16. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  17. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  18. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  19. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  20. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  21. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  22. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  23. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  24. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  25. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  26. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  27. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  28. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  29. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  30. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  31. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  32. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  33. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  34. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  35. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  36. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  37. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  38. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  39. package/src/components/ui/finance/invoices/types.ts +3 -3
  40. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  41. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  42. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  43. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  44. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  45. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  46. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  47. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  48. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  49. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  50. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  51. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
  52. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  53. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  54. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  55. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  56. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  57. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  58. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  59. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  60. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  61. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  62. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  63. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  64. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  65. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  66. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  67. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  68. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  69. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
  70. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
  71. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  72. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  73. package/src/components/ui/text-editor/editor.tsx +3 -0
  74. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  75. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  76. package/src/components/ui/text-editor/tool-bar.tsx +18 -74
  77. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  78. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  79. package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
  80. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
  81. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  82. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  83. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
  84. package/src/hooks/calendar-connection-types.ts +49 -0
  85. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  86. package/src/hooks/calendar-event-write-payload.ts +33 -0
  87. package/src/hooks/collaboration-document-hydration.ts +35 -0
  88. package/src/hooks/supabase-provider.ts +39 -41
  89. package/src/hooks/use-calendar-sync.tsx +41 -62
  90. package/src/hooks/use-calendar.tsx +24 -38
  91. package/src/hooks/use-local-storage.ts +9 -11
  92. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  93. package/src/hooks/use-yjs-collaboration.ts +23 -0
@@ -1,5 +1,6 @@
1
1
  import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
2
2
  import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
3
+ import { isProviderRecurrenceReadonly } from '@tuturuuu/utils/calendar-provider-readonly';
3
4
 
4
5
  export function calendarEventUpdatePayload(
5
6
  input: Partial<CalendarEvent>
@@ -22,3 +23,35 @@ export function calendarEventUpdatePayload(
22
23
  payload.color = input.color;
23
24
  return payload;
24
25
  }
26
+
27
+ export function cleanCalendarEventUpdates(input: Partial<CalendarEvent>) {
28
+ const result: Partial<CalendarEvent> = {};
29
+ for (const field of [
30
+ 'title',
31
+ 'description',
32
+ 'start_at',
33
+ 'end_at',
34
+ 'color',
35
+ 'location',
36
+ 'locked',
37
+ 'source',
38
+ ] as const) {
39
+ if (input[field] !== undefined)
40
+ Object.assign(result, { [field]: input[field] });
41
+ }
42
+ return result;
43
+ }
44
+ export function calendarEventByIdentity(events: CalendarEvent[], id: string) {
45
+ return events.find((event) => event.id === id || event._originalId === id);
46
+ }
47
+ export function assertOrdinaryCalendarEvent(
48
+ events: CalendarEvent[],
49
+ id: string
50
+ ) {
51
+ if (isProviderRecurrenceReadonly(calendarEventByIdentity(events, id)))
52
+ throw new Error('Unsupported provider recurrence is read only');
53
+ if (calendarEventByIdentity(events, id)?.seriesId)
54
+ throw new Error(
55
+ 'Recurring occurrences require an explicit series edit scope'
56
+ );
57
+ }
@@ -0,0 +1,35 @@
1
+ interface DocumentHydrationOptions {
2
+ load: () => Promise<number[] | null>;
3
+ apply: (state: Uint8Array) => void;
4
+ isActive: () => boolean;
5
+ onLoaded: () => void;
6
+ onError: () => void;
7
+ }
8
+
9
+ /** Coalesces durable reads and fences late completion after resource teardown. */
10
+ export class DocumentHydration {
11
+ private pending: Promise<boolean> | undefined;
12
+
13
+ constructor(private readonly options: DocumentHydrationOptions) {}
14
+
15
+ load(): Promise<boolean> {
16
+ if (this.pending) return this.pending;
17
+ this.pending = this.read().finally(() => {
18
+ this.pending = undefined;
19
+ });
20
+ return this.pending;
21
+ }
22
+
23
+ private async read(): Promise<boolean> {
24
+ try {
25
+ const state = await this.options.load();
26
+ if (!this.options.isActive()) return false;
27
+ if (state?.length) this.options.apply(Uint8Array.from(state));
28
+ this.options.onLoaded();
29
+ return true;
30
+ } catch {
31
+ if (this.options.isActive()) this.options.onError();
32
+ return false;
33
+ }
34
+ }
35
+ }
@@ -7,6 +7,7 @@ import debug from 'debug';
7
7
  import { EventEmitter } from 'eventemitter3';
8
8
  import * as awarenessProtocol from 'y-protocols/awareness';
9
9
  import * as Y from 'yjs';
10
+ import { DocumentHydration } from './collaboration-document-hydration';
10
11
  import { isPageVisible } from './use-page-visibility';
11
12
 
12
13
  export const SUPABASE_PROVIDER_SYNC_ORIGIN = Symbol.for(
@@ -31,6 +32,9 @@ export interface SupabaseProviderConfig {
31
32
  export default class SupabaseProvider extends EventEmitter {
32
33
  public awareness: awarenessProtocol.Awareness;
33
34
  public connected = false;
35
+ public hydrated = false;
36
+ private connectionGeneration = 0;
37
+ private hydration: DocumentHydration;
34
38
  private channel: RealtimeChannel | null = null;
35
39
  private realtime = createRealtimeClient();
36
40
  private pendingAwarenessClients = new Set<number>();
@@ -78,12 +82,10 @@ export default class SupabaseProvider extends EventEmitter {
78
82
  }
79
83
 
80
84
  if (!this.connected || this.destroyed) {
81
- this.logger('skipping broadcast - not connected or destroyed');
82
85
  return;
83
86
  }
84
87
 
85
88
  if (!update || update.length === 0) {
86
- this.logger('skipping broadcast - empty update');
87
89
  return;
88
90
  }
89
91
 
@@ -125,7 +127,6 @@ export default class SupabaseProvider extends EventEmitter {
125
127
 
126
128
  this.saveTimeout = setTimeout(() => {
127
129
  this.saveTimeout = undefined;
128
- this.logger('debounce period elapsed, saving to database');
129
130
  void this.save();
130
131
  }, this.saveDebounceMs);
131
132
  }
@@ -145,7 +146,6 @@ export default class SupabaseProvider extends EventEmitter {
145
146
  if (this.saveTimeout) {
146
147
  clearTimeout(this.saveTimeout);
147
148
  this.saveTimeout = undefined;
148
- this.logger('flushing pending save immediately');
149
149
  await this.save();
150
150
  }
151
151
  }
@@ -269,39 +269,20 @@ export default class SupabaseProvider extends EventEmitter {
269
269
  }
270
270
 
271
271
  private async onConnect() {
272
- this.logger('connected');
273
-
274
272
  this.reconnectAttempts = 0;
275
273
  if (this.reconnectTimeout) {
276
274
  clearTimeout(this.reconnectTimeout);
277
275
  this.reconnectTimeout = undefined;
278
276
  }
279
277
 
280
- const persistedState = this.config.loadState
281
- ? await this.config.loadState()
282
- : await (async () => {
283
- const { data, status } = await this.supabase
284
- .from(this.config.tableName as any)
285
- .select<string, { [key: string]: number[] }>(
286
- `${this.config.columnName}`
287
- )
288
- .eq(this.config.idName || 'id', this.config.id)
289
- .single();
290
-
291
- this.logger('retrieved data from supabase', status);
292
- return data?.[this.config.columnName] ?? null;
293
- })();
294
-
295
- if (persistedState && persistedState.length > 0) {
296
- this.logger('applying update to yjs');
297
- try {
298
- this.applyUpdate(Uint8Array.from(persistedState), this);
299
- } catch (error) {
300
- this.logger(error);
301
- }
302
- }
278
+ const generation = this.connectionGeneration;
279
+ if (
280
+ !(await this.hydration.load()) ||
281
+ this.destroyed ||
282
+ generation !== this.connectionGeneration
283
+ )
284
+ return;
303
285
 
304
- this.logger('setting connected flag to true');
305
286
  this.isOnline(true);
306
287
 
307
288
  this.synced = true;
@@ -416,9 +397,33 @@ export default class SupabaseProvider extends EventEmitter {
416
397
  this.logger = debug(`y-${doc.clientID}`);
417
398
  this.logger.enabled = true;
418
399
 
419
- this.logger('constructor initializing');
420
- this.logger('connecting to Cloudflare realtime', doc.guid);
421
- this.logger(`save debounce set to ${this.saveDebounceMs}ms`);
400
+ this.hydration = new DocumentHydration({
401
+ load:
402
+ this.config.loadState ??
403
+ (async () => {
404
+ const { data, error } = await this.supabase
405
+ .from(this.config.tableName as any)
406
+ .select<string, { [key: string]: number[] }>(this.config.columnName)
407
+ .eq(this.config.idName || 'id', this.config.id)
408
+ .single();
409
+ if (error) throw error;
410
+ return data?.[this.config.columnName] ?? null;
411
+ }),
412
+ apply: (state) => Y.applyUpdate(this.doc, state, this),
413
+ isActive: () => !this.destroyed,
414
+ onLoaded: () => {
415
+ this.hydrated = true;
416
+ this.emit('hydrated');
417
+ },
418
+ onError: () =>
419
+ this.emit('error', {
420
+ message: 'Unable to load collaboration document',
421
+ channel: this.config.channel,
422
+ status: 'DOCUMENT_ERROR',
423
+ }),
424
+ });
425
+ // Durable reads must not depend on a successful peer transport connection.
426
+ void this.hydration.load();
422
427
 
423
428
  if (
424
429
  this.config.resyncInterval ||
@@ -438,12 +443,10 @@ export default class SupabaseProvider extends EventEmitter {
438
443
  }
439
444
 
440
445
  if (!isPageVisible()) {
441
- this.logger('skipping resync - page not visible');
442
446
  return;
443
447
  }
444
448
 
445
449
  if (!this._dirty) {
446
- this.logger('skipping resync - no local changes');
447
450
  return;
448
451
  }
449
452
 
@@ -542,7 +545,6 @@ export default class SupabaseProvider extends EventEmitter {
542
545
 
543
546
  public onConnecting() {
544
547
  if (!this.isOnline()) {
545
- this.logger('connecting');
546
548
  this.emit('status', [{ status: 'connecting' }]);
547
549
  }
548
550
  }
@@ -551,7 +553,6 @@ export default class SupabaseProvider extends EventEmitter {
551
553
  if (this.destroyed || this.reconnectTimeout) return;
552
554
 
553
555
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
554
- this.logger('max reconnect attempts reached');
555
556
  this.emit('reconnect-failed');
556
557
  return;
557
558
  }
@@ -581,7 +582,6 @@ export default class SupabaseProvider extends EventEmitter {
581
582
  public resetAndReconnect() {
582
583
  if (this.destroyed || this.connected) return;
583
584
 
584
- this.logger('resetAndReconnect: resetting backoff and reconnecting');
585
585
  this.reconnectAttempts = 0;
586
586
 
587
587
  if (this.reconnectTimeout) {
@@ -594,11 +594,10 @@ export default class SupabaseProvider extends EventEmitter {
594
594
  }
595
595
 
596
596
  public onDisconnect() {
597
- this.logger('disconnected');
597
+ this.connectionGeneration++;
598
598
 
599
599
  this.synced = false;
600
600
  this.isOnline(false);
601
- this.logger('set connected flag to false');
602
601
  if (this.isOnline()) {
603
602
  this.emit('status', [{ status: 'disconnected' }]);
604
603
  }
@@ -633,7 +632,6 @@ export default class SupabaseProvider extends EventEmitter {
633
632
  if (!message) {
634
633
  this.logger(`Permission denied to channel`);
635
634
  }
636
- this.logger('processed message (type = MessageAuth)');
637
635
  }
638
636
 
639
637
  public destroy() {
@@ -21,6 +21,14 @@ import {
21
21
  } from 'react';
22
22
  import { toast } from '../components/ui/sonner';
23
23
  import { calendarQueryRange } from '../lib/calendar-day';
24
+ import type {
25
+ CalendarConnection,
26
+ CalendarOptimisticStatus,
27
+ OptimisticCalendarPatchOptions,
28
+ OptimisticCalendarState,
29
+ OptimisticCalendarSyncEvent,
30
+ CalendarSyncStatus as SyncStatus,
31
+ } from './calendar-connection-types';
24
32
  import { runCalendarProviderSync } from './calendar-provider-sync';
25
33
  import {
26
34
  type CacheUpdate,
@@ -32,22 +40,10 @@ import {
32
40
  calendarRangeCacheKey,
33
41
  calendarRangeIncludesToday,
34
42
  } from './calendar-sync-range';
35
-
36
- // Type for calendar connection
37
- type CalendarConnection = {
38
- id: string;
39
- ws_id: string;
40
- calendar_id: string;
41
- calendar_name: string;
42
- is_enabled: boolean;
43
- color: string | null;
44
- provider?: 'google' | 'microsoft' | string;
45
- auth_token_id?: string | null;
46
- workspace_calendar_id?: string | null;
47
- access_role?: string | null;
48
- created_at: string;
49
- updated_at: string;
50
- };
43
+ import {
44
+ nativeOccurrencesKey,
45
+ useNativeCalendarOccurrences,
46
+ } from './use-native-calendar-occurrences';
51
47
 
52
48
  // Extended CalendarEvent type with habit flags
53
49
  export type CalendarEventWithHabitInfo = CalendarEvent & {
@@ -56,40 +52,7 @@ export type CalendarEventWithHabitInfo = CalendarEvent & {
56
52
  _optimisticStatus?: CalendarOptimisticStatus;
57
53
  };
58
54
 
59
- // Sync status type
60
- type SyncStatus = {
61
- state: 'idle' | 'syncing' | 'success' | 'error';
62
- message?: string;
63
- lastSyncTime?: Date;
64
- direction?: 'google-to-tuturuuu' | 'tuturuuu-to-google' | 'both';
65
- };
66
-
67
- export type CalendarOptimisticStatus =
68
- | 'creating'
69
- | 'updating'
70
- | 'deleting'
71
- | 'error';
72
-
73
- type OptimisticCalendarSyncEvent = Partial<
74
- Omit<CalendarEvent, 'color' | 'description' | 'location'>
75
- > &
76
- Pick<CalendarEvent, 'id'> & {
77
- color?: CalendarEvent['color'] | string | null;
78
- description?: string | null;
79
- location?: string | null;
80
- _optimisticStatus?: CalendarOptimisticStatus;
81
- };
82
-
83
- type OptimisticCalendarPatchOptions = {
84
- removeIds?: string[];
85
- clearIds?: string[];
86
- status?: CalendarOptimisticStatus;
87
- };
88
-
89
- type OptimisticCalendarState = {
90
- events: Record<string, OptimisticCalendarSyncEvent>;
91
- removedIds: string[];
92
- };
55
+ export type { CalendarOptimisticStatus } from './calendar-connection-types';
93
56
 
94
57
  const CalendarSyncContext = createContext<{
95
58
  data: WorkspaceCalendarEvent[] | null;
@@ -222,6 +185,12 @@ export const CalendarSyncProvider = ({
222
185
  const isForcedRef = useRef<boolean>(false);
223
186
  const lastSyncTimeRef = useRef<number>(0);
224
187
  const queryClient = useQueryClient();
188
+ const nativeOccurrences = useNativeCalendarOccurrences(
189
+ wsId,
190
+ dates,
191
+ timezone,
192
+ hasExternalEvents
193
+ );
225
194
 
226
195
  // Calendar connections state
227
196
  const [calendarConnections, setCalendarConnectionsState] = useState<
@@ -514,6 +483,7 @@ export const CalendarSyncProvider = ({
514
483
  if (!activeCacheKey) return null;
515
484
 
516
485
  isForcedRef.current = true;
486
+ queryClient.invalidateQueries({ queryKey: nativeOccurrencesKey(wsId) });
517
487
 
518
488
  queryClient.invalidateQueries({
519
489
  queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
@@ -641,8 +611,17 @@ export const CalendarSyncProvider = ({
641
611
  () =>
642
612
  hasExternalEvents
643
613
  ? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
644
- : (fetchedData ?? activeCachedDatabaseEvents ?? []),
645
- [activeCachedDatabaseEvents, externalEvents, fetchedData, hasExternalEvents]
614
+ : [
615
+ ...(fetchedData ?? activeCachedDatabaseEvents ?? []),
616
+ ...nativeOccurrences.data,
617
+ ],
618
+ [
619
+ activeCachedDatabaseEvents,
620
+ externalEvents,
621
+ fetchedData,
622
+ hasExternalEvents,
623
+ nativeOccurrences.data,
624
+ ]
646
625
  );
647
626
 
648
627
  const visibleEventsWithOptimisticState = useMemo(() => {
@@ -768,20 +747,16 @@ export const CalendarSyncProvider = ({
768
747
  const eventsWithoutAllDays = useMemo(() => {
769
748
  // Process events immediately when they change
770
749
  return events.filter((event) => {
771
- // Note: We can't access settings here easily, so we use default timezone detection
772
- // This is acceptable since this is used for layout purposes mainly
773
- return !isAllDayEvent(event);
750
+ return !isAllDayEvent(event, timezone);
774
751
  });
775
- }, [events]);
752
+ }, [events, timezone]);
776
753
 
777
754
  const allDayEvents = useMemo(() => {
778
755
  // Process events immediately when they change
779
756
  return events.filter((event) => {
780
- // Note: We can't access settings here easily, so we use default timezone detection
781
- // This is acceptable since this is used for layout purposes mainly
782
- return isAllDayEvent(event);
757
+ return isAllDayEvent(event, timezone);
783
758
  });
784
- }, [events]);
759
+ }, [events, timezone]);
785
760
 
786
761
  const syncToGoogle = useCallback(async () => {
787
762
  toast.info('Provider events sync when you create or edit them.');
@@ -798,7 +773,7 @@ export const CalendarSyncProvider = ({
798
773
  ? ((externalEvents ?? []) as WorkspaceCalendarEvent[])
799
774
  : (fetchedData ?? activeCachedDatabaseEvents ?? null),
800
775
  googleData,
801
- error,
776
+ error: error ?? nativeOccurrences.error,
802
777
  dates,
803
778
  setDates,
804
779
  timezone,
@@ -828,7 +803,11 @@ export const CalendarSyncProvider = ({
828
803
  syncStatus,
829
804
 
830
805
  // Loading states
831
- isLoading: externalEventsLoading || isDatabaseLoading || isGoogleLoading,
806
+ isLoading:
807
+ externalEventsLoading ||
808
+ isDatabaseLoading ||
809
+ isGoogleLoading ||
810
+ (!hasExternalEvents && dates.length > 0 && nativeOccurrences.isLoading),
832
811
  isSyncing,
833
812
  };
834
813
 
@@ -26,6 +26,7 @@ import {
26
26
  useRef,
27
27
  useState,
28
28
  } from 'react';
29
+ import { NativeRecurrenceContextMenu } from '../components/ui/legacy/calendar/native-recurrence-context-menu';
29
30
  import { getTaskApiUrl } from '../lib/tasks-app-url';
30
31
  import {
31
32
  createCalendarCreationRequests,
@@ -33,7 +34,13 @@ import {
33
34
  } from './calendar-creation-request';
34
35
  import { roundToNearest15Minutes } from './calendar-date-utils';
35
36
  import { createCalendarEventLookup } from './calendar-event-lookup';
36
- import { calendarEventUpdatePayload } from './calendar-event-write-payload';
37
+ import type { PendingEventUpdate } from './calendar-event-update-queue-types';
38
+ import {
39
+ assertOrdinaryCalendarEvent,
40
+ calendarEventByIdentity,
41
+ calendarEventUpdatePayload,
42
+ cleanCalendarEventUpdates,
43
+ } from './calendar-event-write-payload';
37
44
  import { useCalendarSync } from './use-calendar-sync';
38
45
  import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
39
46
 
@@ -229,18 +236,6 @@ const CalendarContext = createContext<{
229
236
  readOnly: false,
230
237
  });
231
238
 
232
- // Add this interface before the updateEvent function
233
- interface PendingEventUpdate extends Partial<CalendarEvent> {
234
- _updateId?: string;
235
- _timestamp: number;
236
- _eventId: string;
237
- _previousEvent?: CalendarEvent;
238
- _resolvers?: Array<{
239
- resolve: (value: CalendarEvent) => void;
240
- reject: (reason: unknown) => void;
241
- }>;
242
- }
243
-
244
239
  export type CalendarEventAdapter = {
245
240
  disableBuiltInEventUi?: boolean;
246
241
  preservePastEventOpacity?: boolean;
@@ -484,11 +479,7 @@ export const CalendarProvider = ({
484
479
  // Event getters
485
480
  const getEvent = useCallback(
486
481
  (eventId: string) => {
487
- // Handle IDs for split multi-day events (they contain a dash and date)
488
- const originalId = eventId.includes('-')
489
- ? eventId.split('-')[0]
490
- : eventId;
491
- return events.find((e: Partial<CalendarEvent>) => e.id === originalId);
482
+ return calendarEventByIdentity(events, eventId);
492
483
  },
493
484
  [events]
494
485
  );
@@ -917,24 +908,8 @@ export const CalendarProvider = ({
917
908
  return undefined;
918
909
  }
919
910
 
920
- // Clean and validate the event updates - only allow known CalendarEvent fields
921
- const allowedFields: (keyof CalendarEvent)[] = [
922
- 'title',
923
- 'description',
924
- 'start_at',
925
- 'end_at',
926
- 'color',
927
- 'location',
928
- 'locked',
929
- 'source',
930
- ];
931
-
932
- const cleanedUpdates: Partial<CalendarEvent> = {};
933
- for (const field of allowedFields) {
934
- if (eventUpdates[field] !== undefined) {
935
- (cleanedUpdates as any)[field] = eventUpdates[field];
936
- }
937
- }
911
+ assertOrdinaryCalendarEvent(events, eventId);
912
+ const cleanedUpdates = cleanCalendarEventUpdates(eventUpdates);
938
913
 
939
914
  // Round start and end times to nearest 15-minute interval if they exist
940
915
  if (cleanedUpdates.start_at) {
@@ -1075,6 +1050,7 @@ export const CalendarProvider = ({
1075
1050
  console.warn('Calendar is in read-only mode');
1076
1051
  return;
1077
1052
  }
1053
+ assertOrdinaryCalendarEvent(events, eventId);
1078
1054
  // If this is a pending new event that hasn't been saved yet
1079
1055
  if (pendingNewEvent && eventId === 'new') {
1080
1056
  // Just clear the pending event
@@ -1618,8 +1594,18 @@ export const CalendarProvider = ({
1618
1594
  defaultNewEventTab,
1619
1595
  disableBuiltInEventUi: eventAdapter?.disableBuiltInEventUi ?? false,
1620
1596
  preservePastEventOpacity: eventAdapter?.preservePastEventOpacity ?? false,
1621
- renderEventContextMenu: eventAdapter?.renderContextMenu,
1622
- isEventReadOnly: eventAdapter?.isEventReadOnly ?? (() => false),
1597
+ renderEventContextMenu: (event: CalendarEvent) =>
1598
+ event.seriesId ? (
1599
+ <NativeRecurrenceContextMenu
1600
+ onEdit={() => openEventEditor(event._originalId ?? event.id)}
1601
+ readOnly={readOnly}
1602
+ />
1603
+ ) : (
1604
+ eventAdapter?.renderContextMenu?.(event)
1605
+ ),
1606
+ isEventReadOnly: (event: CalendarEvent) =>
1607
+ Boolean(event.seriesId) ||
1608
+ (eventAdapter?.isEventReadOnly?.(event) ?? false),
1623
1609
  readOnly,
1624
1610
  };
1625
1611
 
@@ -9,16 +9,14 @@ export const useLocalStorage = <T>(
9
9
  const [initialized, setInitialized] = useState(false);
10
10
 
11
11
  useEffect(() => {
12
- // Retrieve from localStorage
13
- const item = window.localStorage.getItem(key);
14
- if (item) {
15
- try {
16
- setStoredValue(JSON.parse(item));
17
- } catch (error) {
18
- console.error(error);
19
- }
12
+ try {
13
+ const item = window.localStorage.getItem(key);
14
+ if (item) setStoredValue(JSON.parse(item));
15
+ } catch {
16
+ console.error('Unable to read browser preferences.');
17
+ } finally {
18
+ setInitialized(true);
20
19
  }
21
- setInitialized(true);
22
20
  }, [key]);
23
21
 
24
22
  const setValue = useCallback(
@@ -29,8 +27,8 @@ export const useLocalStorage = <T>(
29
27
  // Save to localStorage
30
28
  try {
31
29
  window.localStorage.setItem(key, JSON.stringify(valueToStore));
32
- } catch (error) {
33
- console.error(error);
30
+ } catch {
31
+ console.error('Unable to save browser preferences.');
34
32
  }
35
33
  return valueToStore;
36
34
  });
@@ -0,0 +1,85 @@
1
+ 'use client';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { listNativeCalendarOccurrences } from '@tuturuuu/internal-api/calendar-series';
4
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
5
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
+ import { useEffect } from 'react';
7
+ import { calendarQueryRange } from '../lib/calendar-day';
8
+ import { useWorkspaceActor } from './use-workspace-visibility';
9
+ export const EMPTY_NATIVE_OCCURRENCES: CalendarEvent[] = [];
10
+ export const nativeOccurrencesKey = (wsId: string) =>
11
+ ['native-calendar-occurrences', wsId] as const;
12
+ export function deniedNativeOccurrences(error: unknown) {
13
+ return (
14
+ error instanceof InternalApiError &&
15
+ (error.status === 401 ||
16
+ error.status === 404 ||
17
+ (error.status === 403 && error.code !== 'MFA_REQUIRED'))
18
+ );
19
+ }
20
+ /** A denied snapshot is never rendered as stale data. MFA is a recoverable challenge. */
21
+ export function visibleNativeOccurrences(
22
+ data: CalendarEvent[] | undefined,
23
+ error: unknown
24
+ ) {
25
+ return deniedNativeOccurrences(error)
26
+ ? EMPTY_NATIVE_OCCURRENCES
27
+ : (data ?? EMPTY_NATIVE_OCCURRENCES);
28
+ }
29
+ export function useNativeCalendarOccurrences(
30
+ wsId: string,
31
+ dates: Date[],
32
+ timezone: string | undefined,
33
+ external: boolean
34
+ ) {
35
+ const actor = useWorkspaceActor();
36
+ const actorId = actor?.actorId;
37
+ const client = useQueryClient();
38
+ const range = dates.length ? calendarQueryRange(dates, timezone) : undefined;
39
+ const from = range?.start.toISOString();
40
+ const to = range?.end.toISOString();
41
+ const key = [...nativeOccurrencesKey(wsId), actorId, from, to];
42
+ const enabled = !external && !!actor && !!wsId && !!from && !!to;
43
+ useEffect(
44
+ () => () => {
45
+ client.removeQueries({
46
+ queryKey: [...nativeOccurrencesKey(wsId), actorId],
47
+ });
48
+ },
49
+ [client, wsId, actorId]
50
+ );
51
+ const query = useQuery({
52
+ queryKey: key,
53
+ enabled,
54
+ queryFn: async ({ signal }) => {
55
+ actor!.assertActive();
56
+ try {
57
+ const result = await listNativeCalendarOccurrences(
58
+ wsId,
59
+ { from: from!, to: to! },
60
+ { signal }
61
+ );
62
+ actor!.assertActive();
63
+ return result.data;
64
+ } catch (error) {
65
+ if (deniedNativeOccurrences(error))
66
+ client.setQueryData(key, EMPTY_NATIVE_OCCURRENCES);
67
+ throw error;
68
+ }
69
+ },
70
+ staleTime: 30_000,
71
+ gcTime: 30 * 60_000,
72
+ retry: (count, error) =>
73
+ !(
74
+ error instanceof InternalApiError &&
75
+ error.status >= 400 &&
76
+ error.status < 500
77
+ ) && count < 2,
78
+ });
79
+ return {
80
+ ...query,
81
+ data: enabled
82
+ ? visibleNativeOccurrences(query.data, query.error)
83
+ : EMPTY_NATIVE_OCCURRENCES,
84
+ };
85
+ }