@tuturuuu/ui 0.7.0 → 0.9.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 (226) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/biome.json +1 -1
  3. package/package.json +75 -73
  4. package/src/components/ui/accordion.tsx +1 -1
  5. package/src/components/ui/breadcrumb.tsx +1 -1
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +4 -0
  7. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +239 -33
  8. package/src/components/ui/calendar-app/components/load-smart-scheduling-tasks.tsx +143 -0
  9. package/src/components/ui/calendar-app/components/priority-view.tsx +10 -3
  10. package/src/components/ui/calendar-app/components/tasks-sidebar.tsx +4 -116
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +67 -2
  12. package/src/components/ui/calendar.tsx +1 -1
  13. package/src/components/ui/carousel.tsx +1 -1
  14. package/src/components/ui/chat/chat-agent-details-external-thread-panel.test.tsx +1 -1
  15. package/src/components/ui/chat/chat-agent-details-external-thread-panel.tsx +1 -1
  16. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +1 -1
  17. package/src/components/ui/chat/chat-agent-details-operations-panel.tsx +1 -1
  18. package/src/components/ui/chat/chat-agent-details-setup-panel.tsx +1 -1
  19. package/src/components/ui/chat/chat-agent-details-sidebar.test.tsx +1 -1
  20. package/src/components/ui/chat/chat-agent-details-sidebar.tsx +2 -2
  21. package/src/components/ui/chat/chat-agent-details-utils.test.ts +1 -1
  22. package/src/components/ui/chat/chat-agent-details-utils.tsx +1 -1
  23. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +2 -2
  24. package/src/components/ui/checkbox.tsx +1 -1
  25. package/src/components/ui/color-picker.tsx +1 -1
  26. package/src/components/ui/command.tsx +1 -1
  27. package/src/components/ui/context-menu.tsx +5 -1
  28. package/src/components/ui/currency-input.test.tsx +43 -0
  29. package/src/components/ui/currency-input.tsx +1 -1
  30. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +3 -0
  31. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +19 -0
  32. package/src/components/ui/custom/combobox.test.tsx +195 -0
  33. package/src/components/ui/custom/combobox.tsx +273 -156
  34. package/src/components/ui/custom/education/modules/youtube/delete-link-button.tsx +5 -13
  35. package/src/components/ui/custom/facebook-mockup/facebook-mockup.tsx +7 -1
  36. package/src/components/ui/custom/facebook-mockup/form.tsx +1 -1
  37. package/src/components/ui/custom/facebook-mockup/image-upload-field.tsx +1 -1
  38. package/src/components/ui/custom/facebook-mockup/preview.tsx +1 -1
  39. package/src/components/ui/custom/settings-dialog-shell.tsx +2 -1
  40. package/src/components/ui/custom/theme-toggle.tsx +1 -1
  41. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  42. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  43. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  44. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  45. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  46. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  47. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  48. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  49. package/src/components/ui/custom/workspace-select.tsx +8 -3
  50. package/src/components/ui/dialog.test.tsx +52 -0
  51. package/src/components/ui/dialog.tsx +6 -2
  52. package/src/components/ui/dropdown-menu.tsx +5 -1
  53. package/src/components/ui/finance/debts/debt-loan-form.tsx +12 -5
  54. package/src/components/ui/finance/debts/debt-loan-summary.tsx +3 -2
  55. package/src/components/ui/finance/debts/debts-page.test.tsx +54 -5
  56. package/src/components/ui/finance/debts/debts-page.tsx +15 -2
  57. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +3 -5
  58. package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +25 -5
  59. package/src/components/ui/finance/invoices/new-invoice-page.tsx +7 -2
  60. package/src/components/ui/finance/invoices/standard-invoice.tsx +4 -2
  61. package/src/components/ui/finance/invoices/subscription-invoice.tsx +4 -2
  62. package/src/components/ui/finance/invoices/utils.ts +3 -1
  63. package/src/components/ui/finance/transactions/form-content-dialog.tsx +3 -0
  64. package/src/components/ui/finance/transactions/form-types.ts +3 -0
  65. package/src/components/ui/finance/transactions/form.tsx +2 -0
  66. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +2 -0
  67. package/src/components/ui/finance/transactions/period-charts/category-breakdown-dialog.tsx +1 -1
  68. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  69. package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +1 -4
  70. package/src/components/ui/finance/transactions/transactions-create-summary.tsx +3 -0
  71. package/src/components/ui/finance/transactions/transactions-page.tsx +4 -1
  72. package/src/components/ui/finance/wallets/form.test.tsx +51 -3
  73. package/src/components/ui/finance/wallets/form.tsx +15 -4
  74. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +4 -0
  75. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +4 -2
  76. package/src/components/ui/finance/wallets/wallets-data-table.tsx +1 -0
  77. package/src/components/ui/finance/wallets/wallets-page.tsx +5 -2
  78. package/src/components/ui/input-otp.tsx +1 -1
  79. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +28 -39
  80. package/src/components/ui/legacy/calendar/calendar-cell.tsx +2 -0
  81. package/src/components/ui/legacy/calendar/calendar-content.tsx +10 -6
  82. package/src/components/ui/legacy/calendar/calendar-header.tsx +23 -3
  83. package/src/components/ui/legacy/calendar/calendar-loading-skeleton.tsx +135 -0
  84. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +175 -237
  85. package/src/components/ui/legacy/calendar/event-card.test.tsx +177 -0
  86. package/src/components/ui/legacy/calendar/event-card.tsx +220 -131
  87. package/src/components/ui/legacy/calendar/event-modal.tsx +17 -17
  88. package/src/components/ui/legacy/calendar/event-provider-display.tsx +69 -0
  89. package/src/components/ui/legacy/calendar/smart-calendar.test.tsx +86 -4
  90. package/src/components/ui/legacy/calendar/smart-calendar.tsx +32 -2
  91. package/src/components/ui/legacy/meet/create-plan-dialog.tsx +19 -10
  92. package/src/components/ui/money-input.test.tsx +64 -0
  93. package/src/components/ui/money-input.tsx +63 -0
  94. package/src/components/ui/navigation-menu.tsx +1 -1
  95. package/src/components/ui/pagination.tsx +1 -1
  96. package/src/components/ui/radio-group.tsx +1 -1
  97. package/src/components/ui/select.tsx +5 -1
  98. package/src/components/ui/sheet.tsx +1 -1
  99. package/src/components/ui/sidebar.tsx +1 -1
  100. package/src/components/ui/storefront/cart-popover.tsx +61 -0
  101. package/src/components/ui/storefront/cart-summary-parts.tsx +290 -0
  102. package/src/components/ui/storefront/cart-summary.tsx +104 -80
  103. package/src/components/ui/storefront/checkout-overlay.tsx +26 -0
  104. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  105. package/src/components/ui/storefront/image-panel.tsx +6 -0
  106. package/src/components/ui/storefront/index.ts +11 -0
  107. package/src/components/ui/storefront/listing-card.tsx +84 -22
  108. package/src/components/ui/storefront/merch-sections.tsx +70 -0
  109. package/src/components/ui/storefront/product-detail.tsx +289 -0
  110. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  111. package/src/components/ui/storefront/storefront-surface.test.tsx +221 -3
  112. package/src/components/ui/storefront/storefront-surface.tsx +288 -153
  113. package/src/components/ui/storefront/types.ts +27 -1
  114. package/src/components/ui/storefront/utils.ts +117 -27
  115. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  116. package/src/components/ui/text-editor/__tests__/extensions.test.ts +123 -0
  117. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  118. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  119. package/src/components/ui/text-editor/background-color-extension.ts +62 -0
  120. package/src/components/ui/text-editor/color-controls.tsx +284 -0
  121. package/src/components/ui/text-editor/content-migration.ts +41 -18
  122. package/src/components/ui/text-editor/editor.tsx +69 -14
  123. package/src/components/ui/text-editor/extensions.ts +9 -3
  124. package/src/components/ui/text-editor/highlight-extension.ts +22 -0
  125. package/src/components/ui/text-editor/image-extension.ts +40 -18
  126. package/src/components/ui/text-editor/tool-bar.tsx +9 -16
  127. package/src/components/ui/text-editor/video-extension.ts +11 -2
  128. package/src/components/ui/toast.tsx +1 -1
  129. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +270 -0
  130. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  131. package/src/components/ui/tu-do/boards/board-public-link-section.tsx +231 -0
  132. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +222 -109
  133. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  134. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +113 -46
  135. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-clear-delete.ts +2 -0
  136. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-move.ts +5 -0
  137. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-mutations-updates.ts +3 -0
  138. package/src/components/ui/tu-do/boards/boardId/kanban/data/kanban-deadline-query.ts +50 -2
  139. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/__tests__/column-reorder.test.ts +17 -0
  140. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/column-reorder.ts +4 -1
  141. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +51 -9
  142. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-order.ts +2 -8
  143. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-sort-key.ts +47 -0
  144. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  145. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +127 -38
  146. package/src/components/ui/tu-do/boards/boardId/kanban/planner/__tests__/kanban-planner-island.test.tsx +380 -0
  147. package/src/components/ui/tu-do/boards/boardId/kanban/planner/kanban-planner-dialog.tsx +204 -0
  148. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-digest-panel.tsx +61 -0
  149. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-item-strip.tsx +54 -0
  150. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-plan-toolbar.tsx +251 -0
  151. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-scope-badge.tsx +27 -0
  152. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-section.tsx +58 -0
  153. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-share-dialog.tsx +238 -0
  154. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-target-controls.tsx +143 -0
  155. package/src/components/ui/tu-do/boards/boardId/kanban/planner/planner-utils.ts +65 -0
  156. package/src/components/ui/tu-do/boards/boardId/kanban/planner/use-kanban-planner-state.ts +234 -0
  157. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +410 -4
  158. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +106 -14
  159. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +443 -19
  160. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +94 -32
  161. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +213 -106
  162. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +186 -0
  163. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +59 -2
  164. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +3 -0
  165. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-comparator.ts +3 -0
  166. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +191 -28
  167. package/src/components/ui/tu-do/boards/boardId/task-filter.test.tsx +152 -0
  168. package/src/components/ui/tu-do/boards/boardId/task-filter.tsx +555 -545
  169. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +7 -0
  170. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  171. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  172. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  173. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  174. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  175. package/src/components/ui/tu-do/boards/share-section.tsx +100 -0
  176. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  177. package/src/components/ui/tu-do/drafts/draft-convert-dialog.tsx +10 -12
  178. package/src/components/ui/tu-do/drafts/drafts-page.tsx +33 -16
  179. package/src/components/ui/tu-do/initiatives/task-initiatives-client.tsx +56 -88
  180. package/src/components/ui/tu-do/my-tasks/my-tasks-content.tsx +26 -2
  181. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +55 -8
  182. package/src/components/ui/tu-do/notes/note-edit-dialog.tsx +1 -4
  183. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +25 -0
  184. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +341 -38
  185. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +253 -0
  186. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +237 -3
  187. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +17 -0
  188. package/src/components/ui/tu-do/shared/__tests__/task-legacy-route-recovery.test.tsx +16 -0
  189. package/src/components/ui/tu-do/shared/board-client.tsx +2 -7
  190. package/src/components/ui/tu-do/shared/board-config-storage.ts +7 -1
  191. package/src/components/ui/tu-do/shared/board-header.tsx +465 -937
  192. package/src/components/ui/tu-do/shared/board-layout-settings.tsx +165 -136
  193. package/src/components/ui/tu-do/shared/board-switcher.tsx +209 -217
  194. package/src/components/ui/tu-do/shared/board-views.tsx +596 -82
  195. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  196. package/src/components/ui/tu-do/shared/list-view.tsx +227 -1
  197. package/src/components/ui/tu-do/shared/recycle-bin-panel.tsx +142 -94
  198. package/src/components/ui/tu-do/shared/special-task-list-pins.ts +51 -0
  199. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +28 -0
  200. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  201. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  202. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  203. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  204. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  205. package/src/components/ui/tu-do/shared/task-edit-dialog/field-diff-viewer.tsx +3 -2
  206. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.test.tsx +91 -0
  207. package/src/components/ui/tu-do/shared/task-edit-dialog/selective-revert-panel.tsx +123 -78
  208. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +7 -1
  209. package/src/components/ui/tu-do/shared/task-edit-dialog/task-snapshot-dialog.tsx +8 -3
  210. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +44 -15
  211. package/src/components/ui/tu-do/shared/task-legacy-route-recovery.tsx +2 -9
  212. package/src/declarations.d.ts +1 -0
  213. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +322 -2
  214. package/src/hooks/__tests__/use-calendar-sync.test.tsx +446 -0
  215. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  216. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  217. package/src/hooks/use-calendar-sync.tsx +247 -243
  218. package/src/hooks/use-calendar.tsx +323 -138
  219. package/src/hooks/use-task-actions.ts +24 -0
  220. package/src/hooks/use-user-workspace-config.ts +75 -0
  221. package/src/hooks/use-workspace-currency.ts +8 -3
  222. package/src/hooks/useBoardRealtime.ts +6 -3
  223. package/src/hooks/useBoardRealtime.types.ts +11 -0
  224. package/src/hooks/useBoardRealtimeEventHandler.ts +11 -0
  225. package/src/hooks/useCursorTracking.ts +91 -27
  226. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -1,5 +1,7 @@
1
1
  import {
2
+ AlertTriangle,
2
3
  CalendarIcon,
4
+ Check,
3
5
  ChevronLeft,
4
6
  ChevronRight,
5
7
  Moon,
@@ -81,7 +83,7 @@ export function CalendarHeader({
81
83
  return newDate;
82
84
  });
83
85
 
84
- const { isLoading, isSyncing } = useCalendarSync();
86
+ const { syncStatus } = useCalendarSync();
85
87
  const selectToday = () => setDate(new Date());
86
88
  const isTodaySelected = () => dayjs(date).isSame(dayjs(), 'day');
87
89
  const isCurrentMonth = () =>
@@ -112,6 +114,14 @@ export function CalendarHeader({
112
114
  };
113
115
 
114
116
  const LunarIcon = showLunar ? MoonStar : Moon;
117
+ const statusLabel =
118
+ syncStatus.state === 'error'
119
+ ? t('failed_to_load_events')
120
+ : syncStatus.lastSyncTime
121
+ ? `${t('sync_completed')} ${dayjs(syncStatus.lastSyncTime)
122
+ .locale(locale)
123
+ .format('HH:mm')}`
124
+ : null;
115
125
 
116
126
  return (
117
127
  <div className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
@@ -121,8 +131,18 @@ export function CalendarHeader({
121
131
  </div>
122
132
  <div className="flex flex-col gap-2 md:flex-row md:items-center">
123
133
  <div className="flex items-center gap-2">
124
- {(isLoading || isSyncing) && (
125
- <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
134
+ {statusLabel && (
135
+ <div
136
+ aria-live="polite"
137
+ className="hidden min-w-0 items-center gap-1.5 rounded-full border bg-background/80 px-2 py-1 text-muted-foreground text-xs shadow-xs sm:inline-flex"
138
+ >
139
+ {syncStatus.state === 'error' ? (
140
+ <AlertTriangle className="h-3.5 w-3.5 text-dynamic-red" />
141
+ ) : (
142
+ <Check className="h-3.5 w-3.5 text-dynamic-green" />
143
+ )}
144
+ <span className="truncate">{statusLabel}</span>
145
+ </div>
126
146
  )}
127
147
  <div className="flex flex-none items-center justify-center gap-2 md:justify-start">
128
148
  <Button
@@ -0,0 +1,135 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { CalendarView } from '../../../../hooks/use-view-transition';
3
+ import { Skeleton } from '../../skeleton';
4
+ import { DAY_HEIGHT, HOUR_HEIGHT, MIN_COLUMN_WIDTH } from './config';
5
+
6
+ function TimedCalendarSkeleton({ columns }: { columns: number }) {
7
+ const eventPlaceholders = [
8
+ { column: 0, hour: 2.2, span: 1.4, width: 0.82 },
9
+ { column: Math.min(1, columns - 1), hour: 5.1, span: 1.1, width: 0.74 },
10
+ {
11
+ column: Math.max(0, columns - 2),
12
+ hour: 8.4,
13
+ span: 1.8,
14
+ width: 0.78,
15
+ },
16
+ ];
17
+
18
+ return (
19
+ <div
20
+ className="flex h-full overflow-hidden rounded-b-lg border-border border-b border-l bg-background/50 text-center dark:border-zinc-800"
21
+ style={{ minWidth: `${columns * MIN_COLUMN_WIDTH}px` }}
22
+ aria-hidden="true"
23
+ >
24
+ <div className="w-16 shrink-0 border-r bg-muted/20">
25
+ {Array.from({ length: 8 }).map((_, index) => (
26
+ <Skeleton key={index} className="mx-auto mt-7 h-3 w-9 rounded-sm" />
27
+ ))}
28
+ </div>
29
+ <div
30
+ className="relative grid flex-1"
31
+ style={{
32
+ gridTemplateColumns: `repeat(${columns}, minmax(${MIN_COLUMN_WIDTH}px, 1fr))`,
33
+ height: `${DAY_HEIGHT}px`,
34
+ }}
35
+ >
36
+ {Array.from({ length: columns }).map((_, index) => (
37
+ <div
38
+ key={index}
39
+ className="relative border-border/70 border-r last:border-r-0"
40
+ >
41
+ {Array.from({ length: 24 }).map((_, hour) => (
42
+ <div
43
+ key={hour}
44
+ className="border-border/50 border-b"
45
+ style={{ height: `${HOUR_HEIGHT}px` }}
46
+ />
47
+ ))}
48
+ </div>
49
+ ))}
50
+ {eventPlaceholders.map((placeholder, index) => (
51
+ <Skeleton
52
+ key={index}
53
+ className="absolute rounded-md"
54
+ style={{
55
+ left: `calc(${(placeholder.column * 100) / columns}% + 6px)`,
56
+ top: `${placeholder.hour * HOUR_HEIGHT}px`,
57
+ width: `calc(${(placeholder.width * 100) / columns}% - 12px)`,
58
+ height: `${placeholder.span * HOUR_HEIGHT}px`,
59
+ }}
60
+ />
61
+ ))}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function MonthCalendarSkeleton({ columns }: { columns: number }) {
68
+ return (
69
+ <div
70
+ className="grid h-full min-h-[28rem] gap-px overflow-hidden rounded-lg border bg-border"
71
+ style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
72
+ aria-hidden="true"
73
+ >
74
+ {Array.from({ length: columns * 5 }).map((_, index) => (
75
+ <div key={index} className="space-y-2 bg-background p-2">
76
+ <Skeleton className="h-3 w-8 rounded-sm" />
77
+ <Skeleton className="h-4 w-4/5 rounded-sm" />
78
+ <Skeleton className="h-4 w-3/5 rounded-sm" />
79
+ </div>
80
+ ))}
81
+ </div>
82
+ );
83
+ }
84
+
85
+ function AgendaCalendarSkeleton() {
86
+ return (
87
+ <div className="space-y-3 rounded-lg border bg-background p-4" aria-hidden>
88
+ {Array.from({ length: 6 }).map((_, index) => (
89
+ <div key={index} className="flex items-center gap-3">
90
+ <Skeleton className="h-10 w-14 rounded-md" />
91
+ <div className="min-w-0 flex-1 space-y-2">
92
+ <Skeleton className="h-4 w-2/5 rounded-sm" />
93
+ <Skeleton className="h-3 w-3/5 rounded-sm" />
94
+ </div>
95
+ </div>
96
+ ))}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ export function CalendarLoadingSkeleton({
102
+ dates,
103
+ view,
104
+ }: {
105
+ dates: Date[];
106
+ view: CalendarView;
107
+ }) {
108
+ const columns = Math.max(1, dates.length || (view === 'day' ? 1 : 7));
109
+
110
+ if (view === 'month' || view === 'year') {
111
+ return <MonthCalendarSkeleton columns={view === 'year' ? 4 : 7} />;
112
+ }
113
+
114
+ if (view === 'agenda') return <AgendaCalendarSkeleton />;
115
+
116
+ return (
117
+ <div className="h-full min-h-0 space-y-2" aria-busy="true">
118
+ <div
119
+ className={cn(
120
+ 'grid rounded-lg border bg-background/70 p-2',
121
+ columns === 1 && 'max-w-lg'
122
+ )}
123
+ style={{
124
+ gridTemplateColumns: `4rem repeat(${columns}, minmax(${MIN_COLUMN_WIDTH}px, 1fr))`,
125
+ }}
126
+ >
127
+ <Skeleton className="h-5 w-5 self-center justify-self-center rounded-sm" />
128
+ {Array.from({ length: columns }).map((_, index) => (
129
+ <Skeleton key={index} className="mx-1 h-5 rounded-sm" />
130
+ ))}
131
+ </div>
132
+ <TimedCalendarSkeleton columns={columns} />
133
+ </div>
134
+ );
135
+ }
@@ -4,6 +4,7 @@ import { useCalendarSync } from '@tuturuuu/ui/hooks/use-calendar-sync';
4
4
  import dayjs from 'dayjs';
5
5
  import timezone from 'dayjs/plugin/timezone';
6
6
  import { useParams } from 'next/navigation';
7
+ import { useMemo } from 'react';
7
8
  import { CalendarColumn } from './calendar-column';
8
9
  import { DAY_HEIGHT, MAX_LEVEL } from './config';
9
10
  import { EventCard } from './event-card';
@@ -12,6 +13,146 @@ import { useCalendarSettings } from './settings/settings-context';
12
13
 
13
14
  dayjs.extend(timezone);
14
15
 
16
+ type LayoutCalendarEvent = CalendarEvent & {
17
+ _dayKey: string;
18
+ _endMs: number;
19
+ _startMs: number;
20
+ };
21
+
22
+ function getDayKeyFromDate(date: Date, tz?: string) {
23
+ return (tz === 'auto' ? dayjs(date) : dayjs(date).tz(tz)).format(
24
+ 'YYYY-MM-DD'
25
+ );
26
+ }
27
+
28
+ function getDayKeyFromIso(value: string, tz?: string) {
29
+ return (tz === 'auto' ? dayjs(value) : dayjs(value).tz(tz)).format(
30
+ 'YYYY-MM-DD'
31
+ );
32
+ }
33
+
34
+ function withLayoutMetadata(
35
+ event: CalendarEvent,
36
+ tz?: string
37
+ ): LayoutCalendarEvent {
38
+ return {
39
+ ...event,
40
+ _dayKey: getDayKeyFromIso(event.start_at, tz),
41
+ _endMs: new Date(event.end_at).getTime(),
42
+ _startMs: new Date(event.start_at).getTime(),
43
+ };
44
+ }
45
+
46
+ function assignEventLayout(
47
+ visibleEvents: CalendarEvent[],
48
+ tz?: string
49
+ ): CalendarEvent[] {
50
+ const sortedEvents = visibleEvents
51
+ .map((event) => withLayoutMetadata(event, tz))
52
+ .sort((left, right) => left._startMs - right._startMs);
53
+
54
+ const eventLevels = new Map<string, number>();
55
+ const eventOverlapCounts = new Map<string, number>();
56
+ const eventOverlapGroups = new Map<string, string[]>();
57
+ const eventColumns = new Map<string, number>();
58
+ const eventsByDay = new Map<string, LayoutCalendarEvent[]>();
59
+
60
+ for (const event of sortedEvents) {
61
+ const dayEvents = eventsByDay.get(event._dayKey) ?? [];
62
+ dayEvents.push(event);
63
+ eventsByDay.set(event._dayKey, dayEvents);
64
+ }
65
+
66
+ for (const dayEvents of eventsByDay.values()) {
67
+ const sortedDayEvents = [...dayEvents].sort(
68
+ (left, right) => left._startMs - right._startMs
69
+ );
70
+
71
+ const overlapGroups: LayoutCalendarEvent[][] = [];
72
+ let activeGroup: LayoutCalendarEvent[] = [];
73
+ let activeGroupEnd = Number.NEGATIVE_INFINITY;
74
+
75
+ for (const event of sortedDayEvents) {
76
+ if (activeGroup.length === 0 || event._startMs < activeGroupEnd) {
77
+ activeGroup.push(event);
78
+ activeGroupEnd = Math.max(activeGroupEnd, event._endMs);
79
+ } else {
80
+ overlapGroups.push(activeGroup);
81
+ activeGroup = [event];
82
+ activeGroupEnd = event._endMs;
83
+ }
84
+ }
85
+
86
+ if (activeGroup.length > 0) overlapGroups.push(activeGroup);
87
+
88
+ for (const group of overlapGroups) {
89
+ const sortedGroup = [...group].sort((left, right) => {
90
+ if (left._startMs !== right._startMs)
91
+ return left._startMs - right._startMs;
92
+ return right._endMs - right._startMs - (left._endMs - left._startMs);
93
+ });
94
+
95
+ const groupEventColumns = new Map<string, number>();
96
+ const columnEndTimes: number[] = [];
97
+
98
+ for (const event of sortedGroup) {
99
+ let column = columnEndTimes.findIndex(
100
+ (columnEndTime) => event._startMs >= columnEndTime
101
+ );
102
+
103
+ if (column === -1) column = columnEndTimes.length;
104
+
105
+ groupEventColumns.set(event.id, column);
106
+ columnEndTimes[column] = event._endMs;
107
+ }
108
+
109
+ const maxColumn = Math.max(0, ...groupEventColumns.values());
110
+ const orderedEventIds: string[] = [];
111
+
112
+ for (let column = 0; column <= maxColumn; column++) {
113
+ const columnEvents = sortedGroup
114
+ .filter((event) => groupEventColumns.get(event.id) === column)
115
+ .sort(
116
+ (left, right) =>
117
+ right._endMs - right._startMs - (left._endMs - left._startMs)
118
+ );
119
+ orderedEventIds.push(...columnEvents.map((event) => event.id));
120
+ }
121
+
122
+ for (const event of sortedGroup) {
123
+ eventOverlapCounts.set(event.id, sortedGroup.length);
124
+ eventOverlapGroups.set(event.id, orderedEventIds);
125
+ eventColumns.set(event.id, groupEventColumns.get(event.id) ?? 0);
126
+ }
127
+ }
128
+
129
+ const levelEndTimes: number[] = [];
130
+
131
+ for (const event of sortedDayEvents) {
132
+ let level = 0;
133
+ while (
134
+ level < MAX_LEVEL &&
135
+ levelEndTimes[level] !== undefined &&
136
+ event._startMs < levelEndTimes[level]!
137
+ ) {
138
+ level++;
139
+ }
140
+
141
+ level = Math.min(level, MAX_LEVEL - 1);
142
+ eventLevels.set(event.id, level);
143
+ levelEndTimes[level] = event._endMs;
144
+ }
145
+ }
146
+
147
+ return sortedEvents.map((event) => ({
148
+ ...event,
149
+ _column: eventColumns.get(event.id) ?? 0,
150
+ _level: eventLevels.get(event.id) ?? 0,
151
+ _overlapCount: eventOverlapCounts.get(event.id) ?? 1,
152
+ _overlapGroup: eventOverlapGroups.get(event.id) ?? [event.id],
153
+ }));
154
+ }
155
+
15
156
  export const CalendarMatrix = ({
16
157
  dates,
17
158
  overlay,
@@ -55,250 +196,47 @@ export const CalendarEventMatrix = ({ dates }: { dates: Date[] }) => {
55
196
  useCalendar();
56
197
  const tz = settings?.timezone?.timezone;
57
198
 
58
- // When hideNonPreviewEvents is ON, filter out affected events at matrix level
59
- // This ensures proper elevation calculation without affected events
60
- const filteredRealEvents = hideNonPreviewEvents
61
- ? eventsWithoutAllDays.filter(
62
- (e) => !affectedEventIds.has(e.id) || e._isPreview
63
- )
64
- : eventsWithoutAllDays;
65
-
66
- // Merge real events with preview events for visual demo
67
- // Deduplicate by ID: if a preview event has the same ID as a real event,
68
- // prefer the real event (this happens when locked events are included in preview)
69
- const realEventIds = new Set(filteredRealEvents.map((e) => e.id));
70
- const filteredPreviewEvents = previewEvents.filter(
71
- (e) => !realEventIds.has(e.id)
199
+ const visibleDayKeys = useMemo(
200
+ () => new Set(dates.map((date) => getDayKeyFromDate(date, tz))),
201
+ [dates, tz]
72
202
  );
73
- const allEvents = [...filteredRealEvents, ...filteredPreviewEvents];
74
203
 
75
- // Process events to handle multi-day events
76
- // Events ending at exactly midnight are treated as ending on the previous day
77
- const processedEvents = allEvents.flatMap((event) =>
78
- processCalendarEvent(event, tz)
204
+ const filteredRealEvents = useMemo(
205
+ () =>
206
+ hideNonPreviewEvents
207
+ ? eventsWithoutAllDays.filter(
208
+ (event) => !affectedEventIds.has(event.id) || event._isPreview
209
+ )
210
+ : eventsWithoutAllDays,
211
+ [affectedEventIds, eventsWithoutAllDays, hideNonPreviewEvents]
79
212
  );
80
213
 
81
- // Filter events to only include those visible in the current date range
82
- const visibleEvents = processedEvents.filter((event) => {
83
- const eventStart =
84
- tz === 'auto' ? dayjs(event.start_at) : dayjs(event.start_at).tz(tz);
85
- const eventStartDay = eventStart.startOf('day');
86
-
87
- // Check if the event falls within any of the visible dates
88
- return dates.some((date) => {
89
- const dateDay =
90
- tz === 'auto'
91
- ? dayjs(date).startOf('day')
92
- : dayjs(date).tz(tz).startOf('day');
93
- return dateDay.isSame(eventStartDay);
94
- });
95
- });
96
-
97
- // Simple algorithm to assign levels to events
98
- const assignLevels = () => {
99
- // Sort events by start time
100
- const sortedEvents = [...visibleEvents].sort((a, b) => {
101
- const aStart = new Date(a.start_at).getTime();
102
- const bStart = new Date(b.start_at).getTime();
103
- return aStart - bStart;
104
- });
105
-
106
- // Create maps to store event data
107
- const eventLevels = new Map<string, number>();
108
- const eventOverlapCounts = new Map<string, number>();
109
- const eventOverlapGroups = new Map<string, string[]>();
110
- const eventColumns = new Map<string, number>();
111
-
112
- // Group events by day
113
- const eventsByDay = new Map<string, CalendarEvent[]>();
114
-
115
- // Populate the day groups
116
- sortedEvents.forEach((event) => {
117
- const date = new Date(event.start_at);
118
- const dateKey = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
119
-
120
- if (!eventsByDay.has(dateKey)) {
121
- eventsByDay.set(dateKey, []);
122
- }
123
-
124
- const dayEvents = eventsByDay.get(dateKey);
125
- if (dayEvents) {
126
- dayEvents.push(event);
127
- }
128
- });
129
-
130
- // Process each day's events
131
- eventsByDay.forEach((dayEvents) => {
132
- // Sort by start time
133
- const sortedDayEvents = [...dayEvents].sort((a, b) => {
134
- const aStart = new Date(a.start_at).getTime();
135
- const bStart = new Date(b.start_at).getTime();
136
- return aStart - bStart;
137
- });
138
-
139
- // Track end times for each level
140
- const levelEndTimes: number[] = [];
141
-
142
- // Create a more accurate grouping of overlapping events
143
- // Helper function to check if two events overlap
144
- const eventsOverlap = (event1: CalendarEvent, event2: CalendarEvent) => {
145
- const event1Start = new Date(event1.start_at).getTime();
146
- const event1End = new Date(event1.end_at).getTime();
147
- const event2Start = new Date(event2.start_at).getTime();
148
- const event2End = new Date(event2.end_at).getTime();
149
-
150
- return event1Start < event2End && event1End > event2Start;
151
- };
152
-
153
- // Create overlap groups
154
- const overlapGroups: CalendarEvent[][] = [];
155
-
156
- // Process each event to find its overlap group
157
- sortedDayEvents.forEach((event) => {
158
- // Find all groups this event overlaps with
159
- const overlappingGroupIndices: number[] = [];
160
-
161
- for (let i = 0; i < overlapGroups.length; i++) {
162
- const group = overlapGroups[i];
163
- // Check if event overlaps with any event in this group
164
- if (group?.some((groupEvent) => eventsOverlap(event, groupEvent))) {
165
- overlappingGroupIndices.push(i);
166
- }
167
- }
168
-
169
- if (overlappingGroupIndices.length === 0) {
170
- // No overlapping groups, create a new one
171
- overlapGroups.push([event]);
172
- } else {
173
- // Merge all overlapping groups and add this event
174
- const newGroup = [event];
175
-
176
- // Sort indices in descending order to safely remove from array
177
- overlappingGroupIndices.sort((a, b) => b - a);
178
-
179
- // Merge all overlapping groups
180
- overlappingGroupIndices.forEach((index) => {
181
- newGroup.push(...(overlapGroups[index] ?? []));
182
- overlapGroups.splice(index, 1);
183
- });
184
-
185
- // Add the merged group
186
- overlapGroups.push(newGroup);
187
- }
188
- });
189
-
190
- // Now assign column positions using a graph coloring approach
191
- overlapGroups.forEach((group) => {
192
- // For each group, assign column positions
193
- // Column 0 = all non-overlapping events with each other
194
- // Column 1+ = events that overlap with column 0
195
-
196
- // Sort group by start time, then duration (longest first)
197
- const sortedGroup = [...group].sort((a, b) => {
198
- const aStart = new Date(a.start_at).getTime();
199
- const bStart = new Date(b.start_at).getTime();
200
- if (aStart !== bStart) return aStart - bStart;
201
-
202
- const aDuration =
203
- new Date(a.end_at).getTime() - new Date(a.start_at).getTime();
204
- const bDuration =
205
- new Date(b.end_at).getTime() - new Date(b.start_at).getTime();
206
-
207
- // Longer events first
208
- return bDuration - aDuration;
209
- });
210
-
211
- // Assign columns using greedy coloring
212
- const groupEventColumns = new Map<string, number>();
213
- const columnEndTimes: number[] = [];
214
-
215
- sortedGroup.forEach((event) => {
216
- const eventStart = new Date(event.start_at).getTime();
217
- const eventEnd = new Date(event.end_at).getTime();
218
-
219
- // Find the first column where this event can fit
220
- let column = -1;
221
- for (let i = 0; i < columnEndTimes.length; i++) {
222
- if (eventStart >= columnEndTimes[i]!) {
223
- column = i;
224
- break;
225
- }
226
- }
227
-
228
- // If no existing column works, create a new one
229
- if (column === -1) {
230
- column = columnEndTimes.length;
231
- }
232
-
233
- groupEventColumns.set(event.id, column);
234
- columnEndTimes[column] = eventEnd;
235
- });
236
-
237
- // Now create the ordered list based on column assignment
238
- // Column 0 events first (sorted by duration), then column 1, etc.
239
- const maxColumn = Math.max(...Array.from(groupEventColumns.values()));
240
- const orderedEventIds: string[] = [];
241
-
242
- for (let col = 0; col <= maxColumn; col++) {
243
- const colEvents = sortedGroup
244
- .filter((e) => groupEventColumns.get(e.id) === col)
245
- .sort((a, b) => {
246
- const aDuration =
247
- new Date(a.end_at).getTime() - new Date(a.start_at).getTime();
248
- const bDuration =
249
- new Date(b.end_at).getTime() - new Date(b.start_at).getTime();
250
- return bDuration - aDuration; // Longest first
251
- });
252
- orderedEventIds.push(...colEvents.map((e) => e.id));
253
- }
254
-
255
- // For each event in the group, store the ordered group and column number
256
- sortedGroup.forEach((event) => {
257
- eventOverlapCounts.set(event.id, sortedGroup.length);
258
- eventOverlapGroups.set(event.id, orderedEventIds);
259
- eventColumns.set(event.id, groupEventColumns.get(event.id) || 0);
260
- });
261
- });
262
-
263
- // Assign levels (for fallback positioning) using the original algorithm
264
- sortedDayEvents.forEach((event) => {
265
- const eventStart = new Date(event.start_at).getTime();
266
-
267
- // Find the first level where this event can fit
268
- let level = 0;
269
- while (level < MAX_LEVEL) {
270
- if (
271
- !levelEndTimes[level] ||
272
- eventStart >= (levelEndTimes?.[level] ?? 0)
273
- ) {
274
- break;
275
- }
276
- level++;
214
+ const allEvents = useMemo(() => {
215
+ const realEventIds = new Set(filteredRealEvents.map((event) => event.id));
216
+ const filteredPreviewEvents = previewEvents.filter(
217
+ (event) => !realEventIds.has(event.id)
218
+ );
219
+ return [...filteredRealEvents, ...filteredPreviewEvents];
220
+ }, [filteredRealEvents, previewEvents]);
221
+
222
+ const visibleEvents = useMemo(() => {
223
+ const nextVisibleEvents: CalendarEvent[] = [];
224
+
225
+ for (const event of allEvents) {
226
+ for (const processedEvent of processCalendarEvent(event, tz)) {
227
+ if (visibleDayKeys.has(getDayKeyFromIso(processedEvent.start_at, tz))) {
228
+ nextVisibleEvents.push(processedEvent);
277
229
  }
230
+ }
231
+ }
278
232
 
279
- // Cap at maximum level
280
- level = Math.min(level, MAX_LEVEL - 1);
281
-
282
- // Store the level for this event
283
- eventLevels.set(event.id, level);
233
+ return nextVisibleEvents;
234
+ }, [allEvents, tz, visibleDayKeys]);
284
235
 
285
- // Update the end time for this level
286
- levelEndTimes[level] = new Date(event.end_at).getTime();
287
- });
288
- });
289
-
290
- // Return events with assigned levels and overlap information
291
- return sortedEvents.map((event) => ({
292
- ...event,
293
- _level: eventLevels.get(event.id) || 0,
294
- _overlapCount: eventOverlapCounts.get(event.id) || 1,
295
- _overlapGroup: eventOverlapGroups.get(event.id) || [event.id],
296
- _column: eventColumns.get(event.id) || 0,
297
- }));
298
- };
299
-
300
- // Get events with levels assigned
301
- const eventsWithLevels = assignLevels();
236
+ const eventsWithLevels = useMemo(
237
+ () => assignEventLayout(visibleEvents, tz),
238
+ [visibleEvents, tz]
239
+ );
302
240
 
303
241
  const columns = dates.length;
304
242