@tuturuuu/ui 0.22.0 → 0.24.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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/biome.json +1 -1
  3. package/package.json +78 -78
  4. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.test.tsx +121 -0
  5. package/src/components/ui/calendar-app/components/require-workspace-timezone-dialog.tsx +37 -4
  6. package/src/components/ui/chat/ai-message-markdown.tsx +6 -1
  7. package/src/components/ui/chat/chat-sidebar-groups.test.ts +33 -0
  8. package/src/components/ui/chat/chat-sidebar-items.tsx +10 -2
  9. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +29 -1
  10. package/src/components/ui/chat/chat-sidebar-panel.tsx +4 -3
  11. package/src/components/ui/chat/chat-sidebar-sections.ts +24 -10
  12. package/src/components/ui/chat/chat-utils.test.ts +107 -0
  13. package/src/components/ui/chat/chat-workspace-header.tsx +11 -5
  14. package/src/components/ui/chat/chat-workspace.tsx +66 -24
  15. package/src/components/ui/chat/hooks-conversations.ts +9 -1
  16. package/src/components/ui/chat/hooks-messages.ts +1 -0
  17. package/src/components/ui/chat/message-bubble.tsx +49 -4
  18. package/src/components/ui/chat/message-composer.tsx +29 -23
  19. package/src/components/ui/chat/message-markdown.tsx +4 -1
  20. package/src/components/ui/chat/query-keys.ts +7 -1
  21. package/src/components/ui/chat/utils.ts +85 -3
  22. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +72 -0
  23. package/src/components/ui/custom/onboarding-settings-panel.tsx +131 -0
  24. package/src/components/ui/custom/settings-dialog-shell.tsx +55 -18
  25. package/src/components/ui/custom/workspace-select-helpers.ts +50 -0
  26. package/src/components/ui/custom/workspace-select.tsx +37 -38
  27. package/src/components/ui/finance/analytics/analytics-page.tsx +10 -0
  28. package/src/components/ui/finance/analytics/inventory-provider-summary.tsx +110 -0
  29. package/src/components/ui/finance/finance-overview-metrics.tsx +49 -0
  30. package/src/components/ui/finance/transactions/inventory-reconciliation-entry-row.tsx +122 -0
  31. package/src/components/ui/finance/transactions/inventory-reconciliation-panel.tsx +407 -0
  32. package/src/components/ui/finance/transactions/transaction-card.tsx +15 -0
  33. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +31 -0
  34. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +26 -4
  35. package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +22 -0
  36. package/src/components/ui/finance/transactions/transactions-page.tsx +8 -0
  37. package/src/components/ui/finance/wallets/walletId/inventory-wallet-contribution.tsx +84 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +4 -0
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +9 -0
  40. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +27 -10
  41. package/src/components/ui/legacy/meet/edit-plan-dialog.tsx +90 -26
  42. package/src/components/ui/legacy/meet/form.tsx +25 -1
  43. package/src/components/ui/legacy/meet/lib/meet-ics.test.ts +40 -0
  44. package/src/components/ui/legacy/meet/lib/meet-ics.ts +50 -0
  45. package/src/components/ui/legacy/meet/lib/meet-insights.test.ts +76 -0
  46. package/src/components/ui/legacy/meet/lib/meet-insights.ts +219 -0
  47. package/src/components/ui/legacy/meet/lib/meet-timezone.test.ts +53 -0
  48. package/src/components/ui/legacy/meet/lib/meet-timezone.ts +45 -0
  49. package/src/components/ui/legacy/meet/page.tsx +1 -0
  50. package/src/components/ui/legacy/meet/planId/agenda-details.tsx +46 -23
  51. package/src/components/ui/legacy/meet/planId/copy-link-button.tsx +24 -197
  52. package/src/components/ui/legacy/meet/planId/date-planner.tsx +3 -28
  53. package/src/components/ui/legacy/meet/planId/meet-insights-panel.tsx +135 -0
  54. package/src/components/ui/legacy/meet/planId/meet-plan-live-root.tsx +41 -0
  55. package/src/components/ui/legacy/meet/planId/meet-query.ts +26 -0
  56. package/src/components/ui/legacy/meet/planId/meet-suggestions-panel.tsx +463 -0
  57. package/src/components/ui/legacy/meet/planId/page.tsx +58 -18
  58. package/src/components/ui/legacy/meet/planId/plan-details-client.tsx +154 -140
  59. package/src/components/ui/legacy/meet/planId/selectable-day-time.tsx +88 -56
  60. package/src/components/ui/legacy/meet/planId/show-qr-button.tsx +3 -5
  61. package/src/components/ui/legacy/meet/planId/unified-availability.tsx +36 -13
  62. package/src/components/ui/legacy/meet/planId/utility-buttons.tsx +3 -24
  63. package/src/components/ui/legacy/meet/plans-grid.tsx +1 -1
  64. package/src/components/ui/legacy/meet/plans-list-view.tsx +1 -1
  65. package/src/components/ui/nuqs-adapter.tsx +1 -0
  66. package/src/hooks/time-blocking-provider.tsx +127 -211
@@ -1,9 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import type { MeetTogetherPlan } from '@tuturuuu/types/primitives/MeetTogetherPlan';
4
- import { usePathname } from 'next/navigation';
5
- import { useEffect, useState } from 'react';
6
- import CopyLinkButton, { generateTumeetMeUrl } from './copy-link-button';
4
+ import CopyLinkButton, { generateCanonicalMeetUrl } from './copy-link-button';
7
5
  import DownloadAsPNG from './download-as-png';
8
6
  import EmailButton from './email-button';
9
7
  import LoggedInAsButton from './logged-in-as-button';
@@ -18,35 +16,16 @@ export default function UtilityButtons({
18
16
  plan,
19
17
  handlePNG,
20
18
  }: UtilityButtonsProps) {
21
- const pathname = usePathname();
22
- const [url, setUrl] = useState('');
23
-
24
- // const { user } = useTimeBlocking();
25
- // const isCreator = !user?.is_guest && user?.id === plan.creator_id;
26
-
27
- useEffect(() => {
28
- setUrl(`${window.location.origin}${pathname}`);
29
- }, [pathname]);
30
-
31
- const tumeetMeUrl = generateTumeetMeUrl(url);
32
-
33
19
  if (!plan?.id) return null;
20
+ const url = generateCanonicalMeetUrl(plan.id);
34
21
 
35
22
  return (
36
23
  <div className="flex w-full flex-col items-center justify-between gap-4 md:flex-row md:items-start">
37
24
  <div className="flex w-full flex-wrap items-start gap-2">
38
25
  <CopyLinkButton url={url} />
39
26
  <ShowQRButton url={url} />
40
- <EmailButton plan={plan} url={tumeetMeUrl} />
27
+ <EmailButton plan={plan} url={url} />
41
28
  <DownloadAsPNG onClick={handlePNG} />
42
- {/* {isCreator && (
43
- <>
44
- <ConfirmButton
45
- planId={plan.id}
46
- isConfirmPlan={Boolean(plan.is_confirmed)}
47
- />
48
- </>
49
- )} */}
50
29
  </div>
51
30
  <LoggedInAsButton />
52
31
  </div>
@@ -15,7 +15,7 @@ export function PlansGrid({
15
15
  locale,
16
16
  t,
17
17
  user,
18
- path = '/meet-together/plans',
18
+ path = '/meet/plans',
19
19
  }: {
20
20
  plans: MeetTogetherPlanWithParticipants[];
21
21
  locale: string;
@@ -20,7 +20,7 @@ export function PlansListView({
20
20
  locale,
21
21
  t,
22
22
  user,
23
- path = '/meet-together/plans',
23
+ path = '/meet/plans',
24
24
  }: {
25
25
  plans: MeetTogetherPlanWithParticipants[];
26
26
  locale: string;
@@ -0,0 +1 @@
1
+ export { NuqsAdapter } from 'nuqs/adapters/next/app';
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
+ import { useMutation, useQueryClient } from '@tanstack/react-query';
3
4
  import {
4
- createTimeblocks,
5
- deleteTimeblock,
6
- getTimeblocks,
7
- } from '@tuturuuu/apis/meet/actions';
5
+ getMeetPlanSnapshot,
6
+ type MeetPlanSnapshot,
7
+ replaceMeetAvailability,
8
+ } from '@tuturuuu/internal-api';
8
9
  import type {
9
10
  GuestUser,
10
11
  MeetTogetherPlan,
@@ -35,58 +36,11 @@ dayjs.extend(minMax);
35
36
  interface EditingParams {
36
37
  enabled: boolean;
37
38
  mode?: 'add' | 'remove';
38
- initialTouch?: { x: number; y: number };
39
39
  startDate?: Date;
40
40
  endDate?: Date;
41
41
  tentativeMode?: boolean;
42
42
  }
43
43
 
44
- // Utility function to compare timeblock arrays
45
- const areTimeBlockArraysEqual = (arr1: Timeblock[], arr2: Timeblock[]) => {
46
- if (arr1.length !== arr2.length) return false;
47
-
48
- return arr1.every((tb1) =>
49
- arr2.some(
50
- (tb2) =>
51
- tb1.date === tb2.date &&
52
- tb1.start_time === tb2.start_time &&
53
- tb1.end_time === tb2.end_time
54
- )
55
- );
56
- };
57
-
58
- // Utility function to find timeblocks that exist in arr1 but not in arr2
59
- const findTimeBlocksToRemove = (
60
- serverTimeblocks: Timeblock[],
61
- localTimeblocks: Timeblock[]
62
- ) => {
63
- return serverTimeblocks.filter(
64
- (serverTimeblock: Timeblock) =>
65
- !localTimeblocks.some(
66
- (localTimeblock: Timeblock) =>
67
- localTimeblock.date === serverTimeblock.date &&
68
- localTimeblock.start_time === serverTimeblock.start_time &&
69
- localTimeblock.end_time === serverTimeblock.end_time
70
- )
71
- );
72
- };
73
-
74
- // Utility function to find timeblocks that exist in arr2 but not in arr1
75
- const findTimeBlocksToAdd = (
76
- localTimeblocks: Timeblock[],
77
- serverTimeblocks: Timeblock[]
78
- ) => {
79
- return localTimeblocks.filter(
80
- (localTimeblock: Timeblock) =>
81
- !serverTimeblocks?.some(
82
- (serverTimeblock: Timeblock) =>
83
- serverTimeblock.date === localTimeblock.date &&
84
- serverTimeblock.start_time === localTimeblock.start_time &&
85
- serverTimeblock.end_time === localTimeblock.end_time
86
- )
87
- );
88
- };
89
-
90
44
  const TimeBlockContext = createContext({
91
45
  user: null as PlatformUser | GuestUser | null,
92
46
  originalPlatformUser: null as PlatformUser | null,
@@ -102,7 +56,11 @@ const TimeBlockContext = createContext({
102
56
  displayMode: 'account-switcher' as 'login' | 'account-switcher' | undefined,
103
57
  isDirty: false,
104
58
  isSaving: false,
59
+ canUndo: false,
60
+ canRedo: false,
105
61
  handleSave: () => {},
62
+ undo: () => {},
63
+ redo: () => {},
106
64
 
107
65
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
108
66
  getPreviewUsers: (_: Timeblock[]) =>
@@ -122,10 +80,11 @@ const TimeBlockContext = createContext({
122
80
  setPreviewDate: (_: Date | null) => {},
123
81
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
124
82
  setSelectedTimeBlocks: (_: { planId?: string; data: Timeblock[] }) => {},
125
- edit: (
126
- _: { mode: 'add' | 'remove'; date: Date; tentativeMode?: boolean },
127
- __?: TouchEvent | MouseEvent
128
- ) => {},
83
+ edit: (_: {
84
+ mode: 'add' | 'remove';
85
+ date: Date;
86
+ tentativeMode?: boolean;
87
+ }) => {},
129
88
  endEditing: () => {},
130
89
  setDisplayMode: (
131
90
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -158,6 +117,7 @@ const TimeBlockingProvider = ({
158
117
  children: ReactNode;
159
118
  }) => {
160
119
  const router = useRouter();
120
+ const queryClient = useQueryClient();
161
121
  const [planUsers, setInternalUsers] = useState(users);
162
122
  const [filteredUserIds, setFilteredUserIds] = useState<string[]>([]);
163
123
 
@@ -173,6 +133,8 @@ const TimeBlockingProvider = ({
173
133
  }, [users]);
174
134
 
175
135
  const [previewDate, setPreviewDate] = useState<Date | null>(null);
136
+ const [undoStack, setUndoStack] = useState<Timeblock[][]>([]);
137
+ const [redoStack, setRedoStack] = useState<Timeblock[][]>([]);
176
138
 
177
139
  const setPreviewDateCallback = useCallback((date: Date | null) => {
178
140
  setPreviewDate(date);
@@ -390,66 +352,25 @@ const TimeBlockingProvider = ({
390
352
  );
391
353
 
392
354
  const edit = useCallback(
393
- (
394
- {
395
- mode,
396
- date,
397
- tentativeMode,
398
- }: { mode: 'add' | 'remove'; date: Date; tentativeMode?: boolean },
399
- event?: TouchEvent | MouseEvent
400
- ) => {
401
- const touch =
402
- event && 'touches' in event ? event.touches?.[0] : undefined;
403
-
355
+ ({
356
+ mode,
357
+ date,
358
+ tentativeMode,
359
+ }: {
360
+ mode: 'add' | 'remove';
361
+ date: Date;
362
+ tentativeMode?: boolean;
363
+ }) => {
404
364
  setEditing((prevData) => {
405
365
  const nextMode = prevData?.mode ?? mode;
406
366
  const nextTentativeMode = prevData?.tentativeMode ?? tentativeMode;
407
- const nextTouch =
408
- prevData?.initialTouch ??
409
- (touch
410
- ? {
411
- x: touch.clientX,
412
- y: touch.clientY,
413
- }
414
- : undefined);
415
-
416
367
  const nextStart = prevData?.startDate ?? date;
417
368
 
418
- const touchXDiff =
419
- (touch?.clientX || 0) - (prevData?.initialTouch?.x || 0);
420
-
421
- const touchYDiff =
422
- (touch?.clientY || 0) - (prevData?.initialTouch?.y || 0);
423
-
424
- const nextEnd =
425
- prevData?.initialTouch !== undefined && nextTouch
426
- ? nextStart
427
- ? // Only apply complex calculation if there's significant movement
428
- // For taps with minimal movement, use the same date as start
429
- // Increased thresholds to prevent accidental multi-selection on mobile
430
- Math.abs(touchXDiff) > 30 || Math.abs(touchYDiff) > 30
431
- ? dayjs(nextStart)
432
- .add(Math.floor((touchYDiff / 15) * 1.25) * 15, 'minute')
433
- .add(
434
- // Only add days if horizontal movement is significant (more than 80px)
435
- // and vertical movement is minimal (less than 40px) to avoid accidental cross-day selection
436
- // Also ensure we're actually dragging (not just a tap)
437
- Math.abs(touchXDiff) > 80 && Math.abs(touchYDiff) < 40
438
- ? Math.floor(touchXDiff / 100)
439
- : 0,
440
- 'day'
441
- )
442
- .toDate()
443
- : nextStart // For single taps, end = start
444
- : nextStart
445
- : date;
446
-
447
369
  return {
448
370
  enabled: true,
449
371
  mode: nextMode,
450
372
  startDate: nextStart,
451
- endDate: nextEnd,
452
- initialTouch: nextTouch,
373
+ endDate: date,
453
374
  tentativeMode: nextTentativeMode,
454
375
  };
455
376
  });
@@ -482,6 +403,8 @@ const TimeBlockingProvider = ({
482
403
  endEditingInProgressRef.current = true;
483
404
 
484
405
  try {
406
+ setUndoStack((stack) => [...stack.slice(-19), selectedTimeBlocks.data]);
407
+ setRedoStack([]);
485
408
  setSelectedTimeBlocks((prevTimeblocks) => {
486
409
  const dates = [
487
410
  editing.startDate,
@@ -536,7 +459,23 @@ const TimeBlockingProvider = ({
536
459
  endEditingInProgressRef.current = false;
537
460
  }
538
461
  }, 100); // Increased delay to better prevent rapid calls
539
- }, [plan.id, editing]);
462
+ }, [plan.id, editing, selectedTimeBlocks.data]);
463
+
464
+ const undo = useCallback(() => {
465
+ const previous = undoStack.at(-1);
466
+ if (!previous || !plan.id) return;
467
+ setRedoStack((stack) => [...stack.slice(-19), selectedTimeBlocks.data]);
468
+ setUndoStack((stack) => stack.slice(0, -1));
469
+ setSelectedTimeBlocks({ planId: plan.id, data: previous });
470
+ }, [plan.id, selectedTimeBlocks.data, undoStack]);
471
+
472
+ const redo = useCallback(() => {
473
+ const next = redoStack.at(-1);
474
+ if (!next || !plan.id) return;
475
+ setUndoStack((stack) => [...stack.slice(-19), selectedTimeBlocks.data]);
476
+ setRedoStack((stack) => stack.slice(0, -1));
477
+ setSelectedTimeBlocks({ planId: plan.id, data: next });
478
+ }, [plan.id, redoStack, selectedTimeBlocks.data]);
540
479
 
541
480
  // Cleanup timeout on component unmount to prevent memory leaks
542
481
  useEffect(() => {
@@ -567,16 +506,14 @@ const TimeBlockingProvider = ({
567
506
 
568
507
  const fetchCurrentTimeBlocks = useCallback(
569
508
  async (planId: string) => {
570
- const result = await getTimeblocks(planId);
571
- if (result.error || !result.data) return [];
572
- return result.data
573
- .flat()
574
- .filter(
575
- (tb: Timeblock) =>
576
- tb.user_id === user?.id && tb.is_guest === (user?.is_guest ?? false)
577
- );
509
+ const snapshot = await getMeetPlanSnapshot(planId);
510
+ queryClient.setQueryData(['meet-plan', planId], snapshot);
511
+ return snapshot.timeblocks.filter(
512
+ (tb: Timeblock) =>
513
+ tb.user_id === user?.id && tb.is_guest === (user?.is_guest ?? false)
514
+ );
578
515
  },
579
- [user?.id, user?.is_guest]
516
+ [queryClient, user?.id, user?.is_guest]
580
517
  );
581
518
 
582
519
  const resetLocalTimeblocks = useCallback(async () => {
@@ -589,112 +526,79 @@ const TimeBlockingProvider = ({
589
526
  setIsDirty(false);
590
527
  }, [fetchCurrentTimeBlocks, plan.id, user?.id]);
591
528
 
592
- const syncTimeBlocks = useCallback(async () => {
593
- if (!plan.id || !user?.id) return;
594
-
595
- // Capture values after check to ensure they're not undefined
596
- const planId = plan.id;
597
- const userId = user.id;
598
- const passwordHash = user.password_hash;
599
-
600
- const addTimeBlocksBatch = async (timeblocksToAdd: Timeblock[]) => {
601
- if (planId !== selectedTimeBlocks.planId || timeblocksToAdd.length === 0)
602
- return;
603
- await createTimeblocks(planId, {
604
- user_id: userId,
605
- password_hash: passwordHash,
606
- timeblocks: timeblocksToAdd,
529
+ const availabilityMutation = useMutation({
530
+ mutationFn: async (nextTimeblocks: Timeblock[]) => {
531
+ if (!plan.id || !user?.id) throw new Error('A plan identity is required');
532
+ return replaceMeetAvailability(plan.id, {
533
+ guestId: user.is_guest ? user.id : undefined,
534
+ passwordHash: user.is_guest ? user.password_hash : undefined,
535
+ timeblocks: nextTimeblocks.map((timeblock) => ({
536
+ date: timeblock.date,
537
+ start_time: timeblock.start_time,
538
+ end_time: timeblock.end_time,
539
+ tentative: timeblock.tentative,
540
+ })),
607
541
  });
608
- };
609
-
610
- const removeTimeBlock = async (timeblock: Timeblock) => {
611
- if (planId !== selectedTimeBlocks.planId || !timeblock.id) return;
612
- await deleteTimeblock(planId, timeblock.id, {
613
- user_id: userId,
614
- password_hash: passwordHash,
615
- });
616
- };
542
+ },
543
+ onMutate: async (nextTimeblocks) => {
544
+ if (!plan.id || !user?.id) return {};
545
+ const queryKey = ['meet-plan', plan.id] as const;
546
+ await queryClient.cancelQueries({ queryKey });
547
+ const previous = queryClient.getQueryData<MeetPlanSnapshot>(queryKey);
548
+ if (previous) {
549
+ queryClient.setQueryData<MeetPlanSnapshot>(queryKey, {
550
+ ...previous,
551
+ timeblocks: [
552
+ ...previous.timeblocks.filter(
553
+ (timeblock) =>
554
+ timeblock.user_id !== user.id ||
555
+ timeblock.is_guest !== Boolean(user.is_guest)
556
+ ),
557
+ ...nextTimeblocks.map((timeblock) => ({
558
+ ...timeblock,
559
+ plan_id: plan.id,
560
+ user_id: user.id,
561
+ is_guest: Boolean(user.is_guest),
562
+ })),
563
+ ],
564
+ });
565
+ }
566
+ return { previous };
567
+ },
568
+ onError: (_error, _next, context) => {
569
+ if (plan.id && context?.previous) {
570
+ queryClient.setQueryData(['meet-plan', plan.id], context.previous);
571
+ }
572
+ },
573
+ onSuccess: (snapshot) => {
574
+ if (plan.id) queryClient.setQueryData(['meet-plan', plan.id], snapshot);
575
+ },
576
+ });
617
577
 
618
- const serverTimeblocks = await fetchCurrentTimeBlocks(plan?.id);
619
- const localTimeblocks = selectedTimeBlocks.data;
620
- if (!serverTimeblocks || !localTimeblocks) return;
621
- if (serverTimeblocks.length === 0 && localTimeblocks.length === 0) {
622
- // No changes needed, clear dirty state
623
- clearDirtyStateWithTimeblocks([]);
624
- return;
625
- }
626
- if (serverTimeblocks.length === 0 && localTimeblocks.length > 0) {
627
- await addTimeBlocksBatch(localTimeblocks);
628
- const syncedServerTimeblocks = await fetchCurrentTimeBlocks(plan?.id);
629
- setSelectedTimeBlocks({
630
- planId: plan.id,
631
- data: syncedServerTimeblocks,
632
- });
633
- clearDirtyStateWithTimeblocks(syncedServerTimeblocks);
634
- return;
635
- }
636
- if (serverTimeblocks.length > 0 && localTimeblocks.length === 0) {
637
- await Promise.all(
638
- serverTimeblocks.map((timeblock: Timeblock) =>
639
- removeTimeBlock(timeblock)
640
- )
641
- );
642
- const syncedServerTimeblocks = await fetchCurrentTimeBlocks(plan?.id);
643
- setSelectedTimeBlocks({
644
- planId: plan.id,
645
- data: syncedServerTimeblocks,
646
- });
647
- clearDirtyStateWithTimeblocks(syncedServerTimeblocks);
648
- return;
649
- }
650
- if (areTimeBlockArraysEqual(serverTimeblocks, localTimeblocks)) {
651
- // No changes needed, clear dirty state
652
- clearDirtyStateWithTimeblocks(serverTimeblocks);
653
- return;
654
- }
655
- const timeblocksToRemove = findTimeBlocksToRemove(
656
- serverTimeblocks,
657
- localTimeblocks
578
+ const syncTimeBlocks = useCallback(async () => {
579
+ if (!plan.id || !user?.id) return;
580
+ const snapshot = await availabilityMutation.mutateAsync(
581
+ selectedTimeBlocks.data
658
582
  );
659
- const timeblocksToAdd = findTimeBlocksToAdd(
660
- localTimeblocks,
661
- serverTimeblocks
583
+ const synced = snapshot.timeblocks.filter(
584
+ (timeblock) =>
585
+ timeblock.user_id === user.id &&
586
+ timeblock.is_guest === Boolean(user.is_guest)
662
587
  );
663
- if (timeblocksToRemove.length === 0 && timeblocksToAdd.length === 0) {
664
- // No changes needed, clear dirty state
665
- clearDirtyStateWithTimeblocks(serverTimeblocks);
666
- return;
667
- }
668
- if (timeblocksToRemove.length > 0)
669
- await Promise.all(
670
- timeblocksToRemove.map((timeblock) =>
671
- timeblock.id ? removeTimeBlock(timeblock) : null
672
- )
673
- );
674
- if (timeblocksToAdd.length > 0) await addTimeBlocksBatch(timeblocksToAdd);
675
- const syncedServerTimeblocks = await fetchCurrentTimeBlocks(plan?.id);
676
- setSelectedTimeBlocks({
677
- planId: plan.id,
678
- data: syncedServerTimeblocks,
679
- });
680
-
681
- // Clear dirty state with the synced timeblocks
682
- clearDirtyStateWithTimeblocks(syncedServerTimeblocks);
588
+ setSelectedTimeBlocks({ planId: plan.id, data: synced });
589
+ clearDirtyStateWithTimeblocks(synced);
683
590
  }, [
684
- fetchCurrentTimeBlocks,
591
+ availabilityMutation,
592
+ clearDirtyStateWithTimeblocks,
685
593
  plan.id,
594
+ selectedTimeBlocks.data,
686
595
  user,
687
- selectedTimeBlocks,
688
- clearDirtyStateWithTimeblocks,
689
596
  ]);
690
597
 
691
- // --- Remove the auto-sync useEffect ---
692
- // useEffect(() => { ... if (editing.enabled) return; syncTimeBlocks(); }, [plan.id, user, selectedTimeBlocks, editing.enabled]);
693
-
694
598
  const [isSaving, setIsSaving] = useState(false);
695
599
 
696
600
  // Handle manual save
697
- const handleSave = async () => {
601
+ const handleSave = useCallback(async () => {
698
602
  setIsSaving(true);
699
603
  try {
700
604
  await syncTimeBlocks();
@@ -704,7 +608,15 @@ const TimeBlockingProvider = ({
704
608
  } finally {
705
609
  setIsSaving(false);
706
610
  }
707
- };
611
+ }, [router, syncTimeBlocks]);
612
+
613
+ useEffect(() => {
614
+ if (editing.enabled || !isDirty || !user?.id || plan.is_confirmed) return;
615
+ const timeout = window.setTimeout(() => {
616
+ void handleSave();
617
+ }, 350);
618
+ return () => window.clearTimeout(timeout);
619
+ }, [editing.enabled, handleSave, isDirty, plan.is_confirmed, user?.id]);
708
620
 
709
621
  return (
710
622
  <TimeBlockContext.Provider
@@ -719,7 +631,11 @@ const TimeBlockingProvider = ({
719
631
  displayMode,
720
632
  isDirty,
721
633
  isSaving,
634
+ canUndo: undoStack.length > 0,
635
+ canRedo: redoStack.length > 0,
722
636
  handleSave,
637
+ undo,
638
+ redo,
723
639
  getPreviewUsers,
724
640
  getOpacityForDate,
725
641