@tuturuuu/ui 0.37.2 → 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 +55 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- 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-draft-recovery.ts +34 -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 -1614
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- 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-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -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/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- 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/popover.tsx +3 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- 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/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +45 -102
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
- 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-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +57 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +76 -112
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +44 -82
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +68 -27
|
@@ -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
|
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
deleteWorkspaceCalendarEvent,
|
|
4
4
|
updateWorkspaceCalendarEvent,
|
|
5
5
|
type WorkspaceCalendarEventCreatePayload,
|
|
6
|
-
type WorkspaceCalendarEventUpdatePayload,
|
|
7
6
|
} from '@tuturuuu/internal-api';
|
|
8
7
|
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
9
8
|
import type {
|
|
@@ -27,9 +26,21 @@ import {
|
|
|
27
26
|
useRef,
|
|
28
27
|
useState,
|
|
29
28
|
} from 'react';
|
|
29
|
+
import { NativeRecurrenceContextMenu } from '../components/ui/legacy/calendar/native-recurrence-context-menu';
|
|
30
30
|
import { getTaskApiUrl } from '../lib/tasks-app-url';
|
|
31
|
+
import {
|
|
32
|
+
createCalendarCreationRequests,
|
|
33
|
+
createOptimisticEventId,
|
|
34
|
+
} from './calendar-creation-request';
|
|
31
35
|
import { roundToNearest15Minutes } from './calendar-date-utils';
|
|
32
36
|
import { createCalendarEventLookup } from './calendar-event-lookup';
|
|
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';
|
|
33
44
|
import { useCalendarSync } from './use-calendar-sync';
|
|
34
45
|
import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
|
|
35
46
|
|
|
@@ -39,17 +50,6 @@ type TaskDragData = {
|
|
|
39
50
|
totalDuration?: number;
|
|
40
51
|
};
|
|
41
52
|
|
|
42
|
-
const createOptimisticEventId = () => {
|
|
43
|
-
if (
|
|
44
|
-
typeof crypto !== 'undefined' &&
|
|
45
|
-
typeof crypto.randomUUID === 'function'
|
|
46
|
-
) {
|
|
47
|
-
return `optimistic-${crypto.randomUUID()}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
53
|
const noStoreFetchOptions = {
|
|
54
54
|
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
|
|
55
55
|
fetch(input, { ...init, cache: 'no-store' }),
|
|
@@ -101,7 +101,8 @@ const CalendarContext = createContext<{
|
|
|
101
101
|
getGoogleEvents: () => CalendarEvent[];
|
|
102
102
|
getEventLevel: (eventId: string) => number;
|
|
103
103
|
addEvent: (
|
|
104
|
-
event: Omit<CalendarEvent, 'id'
|
|
104
|
+
event: Omit<CalendarEvent, 'id'>,
|
|
105
|
+
options?: { requestId?: string }
|
|
105
106
|
) => Promise<CalendarEvent | undefined>;
|
|
106
107
|
addEmptyEvent: (date: Date, isAllDay?: boolean) => CalendarEvent;
|
|
107
108
|
addEmptyEventWithDuration: (startDate: Date, endDate: Date) => CalendarEvent;
|
|
@@ -235,18 +236,6 @@ const CalendarContext = createContext<{
|
|
|
235
236
|
readOnly: false,
|
|
236
237
|
});
|
|
237
238
|
|
|
238
|
-
// Add this interface before the updateEvent function
|
|
239
|
-
interface PendingEventUpdate extends Partial<CalendarEvent> {
|
|
240
|
-
_updateId?: string;
|
|
241
|
-
_timestamp: number;
|
|
242
|
-
_eventId: string;
|
|
243
|
-
_previousEvent?: CalendarEvent;
|
|
244
|
-
_resolvers?: Array<{
|
|
245
|
-
resolve: (value: CalendarEvent) => void;
|
|
246
|
-
reject: (reason: unknown) => void;
|
|
247
|
-
}>;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
239
|
export type CalendarEventAdapter = {
|
|
251
240
|
disableBuiltInEventUi?: boolean;
|
|
252
241
|
preservePastEventOpacity?: boolean;
|
|
@@ -265,11 +254,7 @@ export type CalendarEventAdapter = {
|
|
|
265
254
|
) => Promise<CalendarEvent | undefined> | CalendarEvent | undefined;
|
|
266
255
|
};
|
|
267
256
|
|
|
268
|
-
/**
|
|
269
|
-
* Syncs task total_duration after a calendar event is resized or moved.
|
|
270
|
-
* - Uses canonical workspace calendar events as the source of truth
|
|
271
|
-
* - If total scheduled time exceeds task's total_duration, auto-increase it
|
|
272
|
-
*/
|
|
257
|
+
/** Increase task duration when its canonical calendar schedule exceeds it. */
|
|
273
258
|
async function syncTaskDurationAfterEventChange(
|
|
274
259
|
supabase: any,
|
|
275
260
|
eventId: string,
|
|
@@ -442,6 +427,7 @@ export const CalendarProvider = ({
|
|
|
442
427
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
|
443
428
|
const [previewEventId, setPreviewEventId] = useState<string | null>(null);
|
|
444
429
|
const [isModalHidden, setModalHidden] = useState(false);
|
|
430
|
+
const creationRequests = useRef(createCalendarCreationRequests());
|
|
445
431
|
const [pendingNewEvent, setPendingNewEvent] =
|
|
446
432
|
useState<Partial<CalendarEvent> | null>(null);
|
|
447
433
|
const [defaultNewEventTab, setDefaultNewEventTab] = useState<'manual' | 'ai'>(
|
|
@@ -493,11 +479,7 @@ export const CalendarProvider = ({
|
|
|
493
479
|
// Event getters
|
|
494
480
|
const getEvent = useCallback(
|
|
495
481
|
(eventId: string) => {
|
|
496
|
-
|
|
497
|
-
const originalId = eventId.includes('-')
|
|
498
|
-
? eventId.split('-')[0]
|
|
499
|
-
: eventId;
|
|
500
|
-
return events.find((e: Partial<CalendarEvent>) => e.id === originalId);
|
|
482
|
+
return calendarEventByIdentity(events, eventId);
|
|
501
483
|
},
|
|
502
484
|
[events]
|
|
503
485
|
);
|
|
@@ -583,13 +565,15 @@ export const CalendarProvider = ({
|
|
|
583
565
|
|
|
584
566
|
// CRUD operations with Supabase
|
|
585
567
|
const addEvent = useCallback(
|
|
586
|
-
async (
|
|
568
|
+
async (
|
|
569
|
+
event: Omit<CalendarEvent, 'id'>,
|
|
570
|
+
options?: { requestId?: string }
|
|
571
|
+
) => {
|
|
587
572
|
if (readOnly) {
|
|
588
573
|
console.warn('Calendar is in read-only mode');
|
|
589
574
|
return undefined;
|
|
590
575
|
}
|
|
591
576
|
|
|
592
|
-
// Round start and end times to nearest 15-minute interval
|
|
593
577
|
const startDate = roundToNearest15Minutes(new Date(event.start_at));
|
|
594
578
|
const endDate = roundToNearest15Minutes(new Date(event.end_at));
|
|
595
579
|
|
|
@@ -619,7 +603,13 @@ export const CalendarProvider = ({
|
|
|
619
603
|
locked: true,
|
|
620
604
|
task_id: (event as CalendarEvent & { task_id?: string | null }).task_id,
|
|
621
605
|
source: event.source,
|
|
606
|
+
...(event.providerColor && { providerColor: event.providerColor }),
|
|
622
607
|
};
|
|
608
|
+
if (event.providerColor) delete payload.color;
|
|
609
|
+
payload.requestId = creationRequests.current.forAttempt(
|
|
610
|
+
{ wsId: ws.id, ...payload },
|
|
611
|
+
options?.requestId
|
|
612
|
+
);
|
|
623
613
|
const optimisticId = createOptimisticEventId();
|
|
624
614
|
const optimisticEvent = {
|
|
625
615
|
...event,
|
|
@@ -670,10 +660,7 @@ export const CalendarProvider = ({
|
|
|
670
660
|
|
|
671
661
|
const addEmptyEvent = useCallback(
|
|
672
662
|
(date: Date, isAllDay?: boolean) => {
|
|
673
|
-
//
|
|
674
|
-
// This ensures all-day events are created at midnight in the user's timezone rather than UTC
|
|
675
|
-
// The workaround is necessary because dayjs timezone handling for all-day events can be inconsistent
|
|
676
|
-
// across different browsers and timezone configurations
|
|
663
|
+
// Keep all-day boundaries in the user timezone.
|
|
677
664
|
const selectedDate = dayjs(date);
|
|
678
665
|
|
|
679
666
|
let start_at: string;
|
|
@@ -701,10 +688,8 @@ export const CalendarProvider = ({
|
|
|
701
688
|
end_at = endTime.toISOString();
|
|
702
689
|
}
|
|
703
690
|
|
|
704
|
-
// Use default color
|
|
705
691
|
const defaultColor = 'BLUE';
|
|
706
692
|
|
|
707
|
-
// Create a new event with default values
|
|
708
693
|
const newEvent: CalendarEvent = {
|
|
709
694
|
id: 'new',
|
|
710
695
|
title: '',
|
|
@@ -736,14 +721,11 @@ export const CalendarProvider = ({
|
|
|
736
721
|
|
|
737
722
|
const addEmptyEventWithDuration = useCallback(
|
|
738
723
|
(startDate: Date, endDate: Date) => {
|
|
739
|
-
// Round start and end times to nearest 15-minute interval
|
|
740
724
|
const roundedStartDate = roundToNearest15Minutes(startDate);
|
|
741
725
|
const roundedEndDate = roundToNearest15Minutes(endDate);
|
|
742
726
|
|
|
743
|
-
// Use default color
|
|
744
727
|
const defaultColor = 'BLUE';
|
|
745
728
|
|
|
746
|
-
// Create a new event with default values
|
|
747
729
|
const newEvent: CalendarEvent = {
|
|
748
730
|
id: 'new',
|
|
749
731
|
title: '',
|
|
@@ -832,22 +814,7 @@ export const CalendarProvider = ({
|
|
|
832
814
|
|
|
833
815
|
try {
|
|
834
816
|
// Clean up the update data to ensure no undefined values and exclude system fields
|
|
835
|
-
const cleanUpdateData
|
|
836
|
-
...(updateData.title !== undefined && { title: updateData.title }),
|
|
837
|
-
...(updateData.description !== undefined && {
|
|
838
|
-
description: updateData.description,
|
|
839
|
-
}),
|
|
840
|
-
...(updateData.start_at !== undefined && {
|
|
841
|
-
start_at: updateData.start_at,
|
|
842
|
-
}),
|
|
843
|
-
...(updateData.end_at !== undefined && { end_at: updateData.end_at }),
|
|
844
|
-
...(updateData.color !== undefined && { color: updateData.color }),
|
|
845
|
-
...(updateData.location !== undefined && {
|
|
846
|
-
location: updateData.location,
|
|
847
|
-
}),
|
|
848
|
-
...(updateData.locked !== undefined && { locked: updateData.locked }),
|
|
849
|
-
...(updateData.source !== undefined && { source: updateData.source }),
|
|
850
|
-
};
|
|
817
|
+
const cleanUpdateData = calendarEventUpdatePayload(updateData);
|
|
851
818
|
|
|
852
819
|
// ws is guaranteed to be defined here (validated above at line 732)
|
|
853
820
|
const wsId = ws!.id;
|
|
@@ -941,24 +908,8 @@ export const CalendarProvider = ({
|
|
|
941
908
|
return undefined;
|
|
942
909
|
}
|
|
943
910
|
|
|
944
|
-
|
|
945
|
-
const
|
|
946
|
-
'title',
|
|
947
|
-
'description',
|
|
948
|
-
'start_at',
|
|
949
|
-
'end_at',
|
|
950
|
-
'color',
|
|
951
|
-
'location',
|
|
952
|
-
'locked',
|
|
953
|
-
'source',
|
|
954
|
-
];
|
|
955
|
-
|
|
956
|
-
const cleanedUpdates: Partial<CalendarEvent> = {};
|
|
957
|
-
for (const field of allowedFields) {
|
|
958
|
-
if (eventUpdates[field] !== undefined) {
|
|
959
|
-
(cleanedUpdates as any)[field] = eventUpdates[field];
|
|
960
|
-
}
|
|
961
|
-
}
|
|
911
|
+
assertOrdinaryCalendarEvent(events, eventId);
|
|
912
|
+
const cleanedUpdates = cleanCalendarEventUpdates(eventUpdates);
|
|
962
913
|
|
|
963
914
|
// Round start and end times to nearest 15-minute interval if they exist
|
|
964
915
|
if (cleanedUpdates.start_at) {
|
|
@@ -1099,6 +1050,7 @@ export const CalendarProvider = ({
|
|
|
1099
1050
|
console.warn('Calendar is in read-only mode');
|
|
1100
1051
|
return;
|
|
1101
1052
|
}
|
|
1053
|
+
assertOrdinaryCalendarEvent(events, eventId);
|
|
1102
1054
|
// If this is a pending new event that hasn't been saved yet
|
|
1103
1055
|
if (pendingNewEvent && eventId === 'new') {
|
|
1104
1056
|
// Just clear the pending event
|
|
@@ -1642,8 +1594,18 @@ export const CalendarProvider = ({
|
|
|
1642
1594
|
defaultNewEventTab,
|
|
1643
1595
|
disableBuiltInEventUi: eventAdapter?.disableBuiltInEventUi ?? false,
|
|
1644
1596
|
preservePastEventOpacity: eventAdapter?.preservePastEventOpacity ?? false,
|
|
1645
|
-
renderEventContextMenu:
|
|
1646
|
-
|
|
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),
|
|
1647
1609
|
readOnly,
|
|
1648
1610
|
};
|
|
1649
1611
|
|
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery } from '@tanstack/react-query';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
interface ExchangeRatesResponse {
|
|
7
|
-
data: ExchangeRate[];
|
|
8
|
-
date: string | null;
|
|
9
|
-
}
|
|
4
|
+
import { getExchangeRates } from '@tuturuuu/internal-api/finance-exchange-rates';
|
|
10
5
|
|
|
11
6
|
export function useExchangeRates() {
|
|
12
|
-
return useQuery
|
|
7
|
+
return useQuery({
|
|
13
8
|
queryKey: ['exchange-rates', 'latest'],
|
|
14
|
-
queryFn:
|
|
15
|
-
|
|
16
|
-
cache: 'no-store',
|
|
17
|
-
});
|
|
18
|
-
if (!res.ok) throw new Error('Failed to fetch exchange rates');
|
|
19
|
-
return res.json();
|
|
20
|
-
},
|
|
21
|
-
staleTime: 60 * 60 * 1000, // 1 hour — rates update daily
|
|
9
|
+
queryFn: () => getExchangeRates(),
|
|
10
|
+
staleTime: 60 * 60 * 1000,
|
|
22
11
|
});
|
|
23
12
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
|
|
4
|
+
import { createRealtimeClient } from '@tuturuuu/internal-api/realtime';
|
|
3
5
|
import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
|
|
4
|
-
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
5
|
-
import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
|
|
6
6
|
import type { User } from '@tuturuuu/types/primitives/User';
|
|
7
7
|
import { DEV_MODE } from '@tuturuuu/utils/constants';
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
@@ -149,14 +149,14 @@ export function useWorkspacePresence({
|
|
|
149
149
|
if (setupPromiseRef.current) return setupPromiseRef.current;
|
|
150
150
|
|
|
151
151
|
const promise = (async (): Promise<boolean> => {
|
|
152
|
-
const
|
|
152
|
+
const realtime = createRealtimeClient();
|
|
153
153
|
|
|
154
154
|
try {
|
|
155
155
|
if (isCleanedUpRef.current) return false;
|
|
156
156
|
|
|
157
157
|
const {
|
|
158
158
|
data: { user },
|
|
159
|
-
} = await
|
|
159
|
+
} = await realtime.auth.getUser();
|
|
160
160
|
|
|
161
161
|
if (!user?.id || isCleanedUpRef.current) return false;
|
|
162
162
|
|
|
@@ -184,12 +184,12 @@ export function useWorkspacePresence({
|
|
|
184
184
|
|
|
185
185
|
// Clean up any stale channel
|
|
186
186
|
if (channelRef.current) {
|
|
187
|
-
await
|
|
187
|
+
await realtime.removeChannel(channelRef.current);
|
|
188
188
|
channelRef.current = null;
|
|
189
189
|
lastTrackSignatureRef.current = null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
const channel =
|
|
192
|
+
const channel = realtime.channel(channelKey, {
|
|
193
193
|
config: {
|
|
194
194
|
presence: {
|
|
195
195
|
key: user.id,
|
|
@@ -238,7 +238,7 @@ export function useWorkspacePresence({
|
|
|
238
238
|
setupPromiseRef.current = null;
|
|
239
239
|
lastTrackSignatureRef.current = null;
|
|
240
240
|
if (deadCh) {
|
|
241
|
-
|
|
241
|
+
realtime.removeChannel(deadCh).catch(() => {});
|
|
242
242
|
}
|
|
243
243
|
if (
|
|
244
244
|
retryCountRef.current < MAX_RETRIES &&
|
|
@@ -269,7 +269,7 @@ export function useWorkspacePresence({
|
|
|
269
269
|
setupPromiseRef.current = null;
|
|
270
270
|
lastTrackSignatureRef.current = null;
|
|
271
271
|
if (deadCh) {
|
|
272
|
-
|
|
272
|
+
realtime.removeChannel(deadCh).catch(() => {});
|
|
273
273
|
}
|
|
274
274
|
// Attempt reconnection
|
|
275
275
|
if (retryCountRef.current < MAX_RETRIES) {
|
|
@@ -320,7 +320,7 @@ export function useWorkspacePresence({
|
|
|
320
320
|
setupPromiseRef.current = null;
|
|
321
321
|
if (channelRef.current) {
|
|
322
322
|
channelRef.current.untrack?.().catch(() => {});
|
|
323
|
-
|
|
323
|
+
createRealtimeClient().removeChannel(channelRef.current);
|
|
324
324
|
channelRef.current = null;
|
|
325
325
|
lastTrackSignatureRef.current = null;
|
|
326
326
|
}
|