@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.
- package/CHANGELOG.md +27 -0
- package/package.json +6 -6
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- 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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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.
|
|
420
|
-
|
|
421
|
-
|
|
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.
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
921
|
-
const
|
|
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:
|
|
1622
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
33
|
-
console.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
|
+
}
|