@tuturuuu/ui 0.5.0 → 0.6.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -0,0 +1,235 @@
1
+ 'use client';
2
+
3
+ import { ShoppingCart } from '@tuturuuu/icons';
4
+ import type {
5
+ InventoryStorefront,
6
+ InventoryStorefrontListing,
7
+ } from '@tuturuuu/internal-api/inventory';
8
+ import { cn } from '@tuturuuu/utils/format';
9
+ import type { ReactNode } from 'react';
10
+ import { Badge } from '../badge';
11
+ import { StorefrontCartSummary } from './cart-summary';
12
+ import { StorefrontEmptyListings } from './empty-listings';
13
+ import { StorefrontHeroPanel } from './hero-panel';
14
+ import { StorefrontListingCard } from './listing-card';
15
+ import type {
16
+ StorefrontCartLine,
17
+ StorefrontSurfaceLabels,
18
+ StorefrontSurfaceMode,
19
+ } from './types';
20
+ import { mergeStorefrontSurfaceLabels } from './types';
21
+ import {
22
+ getAccentStyle,
23
+ getStorefrontListingLimit,
24
+ sanitizeStorefrontAccentColor,
25
+ storefrontRadiusClasses,
26
+ storefrontSurfaceClasses,
27
+ } from './utils';
28
+
29
+ export function StorefrontSurface({
30
+ cartLines = [],
31
+ checkoutHref,
32
+ className,
33
+ emptyAction,
34
+ headerActions,
35
+ isDemo = false,
36
+ isSubmitting = false,
37
+ labels: labelOverrides,
38
+ listings,
39
+ mode,
40
+ notice,
41
+ onCheckoutSubmit,
42
+ onDecrement,
43
+ onIncrement,
44
+ selectedListingId,
45
+ storefront,
46
+ }: {
47
+ cartLines?: StorefrontCartLine[];
48
+ checkoutHref?: string;
49
+ className?: string;
50
+ emptyAction?: ReactNode;
51
+ headerActions?: ReactNode;
52
+ isDemo?: boolean;
53
+ isSubmitting?: boolean;
54
+ labels?: Partial<StorefrontSurfaceLabels>;
55
+ listings: InventoryStorefrontListing[];
56
+ mode: StorefrontSurfaceMode;
57
+ notice?: ReactNode;
58
+ onCheckoutSubmit?: (formData: FormData) => void;
59
+ onDecrement?: (listingId: string) => void;
60
+ onIncrement?: (listingId: string, maxQuantity: number) => void;
61
+ selectedListingId?: string;
62
+ storefront: InventoryStorefront;
63
+ }) {
64
+ const labels = mergeStorefrontSurfaceLabels(labelOverrides);
65
+ const accentColor = sanitizeStorefrontAccentColor(storefront.accentColor);
66
+ const radius = storefrontRadiusClasses[storefront.cornerStyle];
67
+ const cartEntries = cartLines.flatMap((line) => {
68
+ const listing = listings.find((item) => item.id === line.listingId);
69
+ return listing ? [{ line, listing }] : [];
70
+ });
71
+ const checkoutEntries = cartEntries.filter(({ line, listing }) => {
72
+ const quantity = Math.min(
73
+ line.quantity,
74
+ getStorefrontListingLimit(listing)
75
+ );
76
+ return quantity > 0;
77
+ });
78
+ const total = checkoutEntries.reduce((sum, { line, listing }) => {
79
+ const quantity = Math.min(
80
+ line.quantity,
81
+ getStorefrontListingLimit(listing)
82
+ );
83
+ return sum + listing.price * quantity;
84
+ }, 0);
85
+ const cartQuantity = cartLines.reduce((sum, line) => sum + line.quantity, 0);
86
+ const visibleListings =
87
+ mode === 'product' && selectedListingId
88
+ ? listings.filter((listing) => listing.id === selectedListingId)
89
+ : listings;
90
+ const isCheckout = mode === 'checkout';
91
+ const isPreview = mode === 'preview';
92
+ const showCartListings = mode === 'cart' || isCheckout;
93
+ const listingRows = showCartListings
94
+ ? cartEntries.map(({ listing }) => listing)
95
+ : visibleListings;
96
+ const currency = storefront.currency ?? 'USD';
97
+
98
+ return (
99
+ <main
100
+ className={cn('min-h-dvh bg-background text-foreground', className)}
101
+ style={getAccentStyle(accentColor)}
102
+ >
103
+ {notice ? (
104
+ <div className="border-border border-b bg-muted/35 px-4 py-2 text-center text-muted-foreground text-sm">
105
+ {notice}
106
+ </div>
107
+ ) : null}
108
+
109
+ <header className="border-border border-b bg-background/90 backdrop-blur">
110
+ <div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-3 px-4 py-3">
111
+ <div className="min-w-0">
112
+ <div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
113
+ <span>
114
+ {storefront.visibility === 'private'
115
+ ? labels.privateStore
116
+ : labels.publicStore}
117
+ </span>
118
+ {isDemo ? (
119
+ <Badge variant="secondary">{labels.demoBadge}</Badge>
120
+ ) : null}
121
+ {isPreview ? (
122
+ <Badge
123
+ className="border-border bg-background"
124
+ variant="outline"
125
+ >
126
+ {labels.previewBadge}
127
+ </Badge>
128
+ ) : null}
129
+ {storefront.checkoutMode === 'simulated' ? (
130
+ <Badge
131
+ className="border-border bg-background"
132
+ variant="outline"
133
+ >
134
+ {labels.simulatedBadge}
135
+ </Badge>
136
+ ) : null}
137
+ {storefront.checkoutMode === 'disabled' ? (
138
+ <Badge
139
+ className="border-border bg-background"
140
+ variant="outline"
141
+ >
142
+ {labels.checkoutDisabledBadge}
143
+ </Badge>
144
+ ) : null}
145
+ </div>
146
+ <h1 className="mt-0.5 truncate font-semibold text-xl">
147
+ {storefront.name}
148
+ </h1>
149
+ </div>
150
+ <div className="flex items-center gap-2">
151
+ {headerActions}
152
+ <span
153
+ className={cn(
154
+ 'inline-flex h-9 min-w-12 items-center justify-center gap-2 border bg-card px-3 font-medium text-sm',
155
+ radius
156
+ )}
157
+ >
158
+ <ShoppingCart className="h-4 w-4" />
159
+ {cartQuantity}
160
+ </span>
161
+ </div>
162
+ </div>
163
+ </header>
164
+
165
+ <section className="mx-auto grid max-w-7xl gap-4 px-4 py-5 lg:grid-cols-[minmax(0,1fr)_340px]">
166
+ <div className="min-w-0">
167
+ <StorefrontHeroPanel
168
+ currency={currency}
169
+ labels={labels}
170
+ listingsCount={listings.length}
171
+ radius={radius}
172
+ storefront={storefront}
173
+ />
174
+
175
+ <div
176
+ className={cn(
177
+ 'mt-4',
178
+ storefront.layoutStyle === 'list'
179
+ ? 'grid gap-3'
180
+ : 'grid gap-3 sm:grid-cols-2 xl:grid-cols-3',
181
+ storefront.layoutStyle === 'feature' &&
182
+ '[&>article:first-child]:sm:col-span-2'
183
+ )}
184
+ >
185
+ {listingRows.length === 0 ? (
186
+ <StorefrontEmptyListings
187
+ action={emptyAction}
188
+ labels={labels}
189
+ radius={radius}
190
+ />
191
+ ) : (
192
+ listingRows.map((listing) => {
193
+ const line = cartLines.find(
194
+ (item) => item.listingId === listing.id
195
+ );
196
+
197
+ return (
198
+ <StorefrontListingCard
199
+ currency={currency}
200
+ isList={storefront.layoutStyle === 'list'}
201
+ key={listing.id}
202
+ labels={labels}
203
+ listing={listing}
204
+ onDecrement={onDecrement}
205
+ onIncrement={onIncrement}
206
+ quantity={line?.quantity ?? 0}
207
+ radius={radius}
208
+ showInventoryBadges={storefront.showInventoryBadges}
209
+ surfaceClassName={
210
+ storefrontSurfaceClasses[storefront.surfaceStyle]
211
+ }
212
+ />
213
+ );
214
+ })
215
+ )}
216
+ </div>
217
+ </div>
218
+
219
+ <StorefrontCartSummary
220
+ cartEntries={checkoutEntries}
221
+ checkoutHref={checkoutHref}
222
+ currency={currency}
223
+ isCheckout={isCheckout}
224
+ isPreview={isPreview}
225
+ isSubmitting={isSubmitting}
226
+ labels={labels}
227
+ onCheckoutSubmit={onCheckoutSubmit}
228
+ radius={radius}
229
+ storefront={storefront}
230
+ total={total}
231
+ />
232
+ </section>
233
+ </main>
234
+ );
235
+ }
@@ -0,0 +1,99 @@
1
+ import type { InventoryStorefrontListing } from '@tuturuuu/internal-api/inventory';
2
+
3
+ export type StorefrontCartLine = {
4
+ listingId: string;
5
+ quantity: number;
6
+ };
7
+
8
+ export type StorefrontCartEntry = {
9
+ line: StorefrontCartLine;
10
+ listing: InventoryStorefrontListing;
11
+ };
12
+
13
+ export type StorefrontSurfaceMode =
14
+ | 'cart'
15
+ | 'checkout'
16
+ | 'preview'
17
+ | 'product'
18
+ | 'store';
19
+
20
+ export type StorefrontSurfaceLabels = {
21
+ add: string;
22
+ available: string;
23
+ browse: string;
24
+ bundle: string;
25
+ cart: string;
26
+ checkout: string;
27
+ checkoutDisabled: string;
28
+ checkoutDisabledBadge: string;
29
+ demoBadge: string;
30
+ emptyCart: string;
31
+ emptyListingsDescription: string;
32
+ emptyListingsTitle: string;
33
+ fallbackDescription: string;
34
+ form: {
35
+ email: string;
36
+ name: string;
37
+ note: string;
38
+ phone: string;
39
+ };
40
+ privateStore: string;
41
+ previewBadge: string;
42
+ product: string;
43
+ publicStore: string;
44
+ quantity: string;
45
+ reserve: string;
46
+ reserving: string;
47
+ reservedCopy: string;
48
+ simulatedBadge: string;
49
+ soldOut: string;
50
+ total: string;
51
+ };
52
+
53
+ export const defaultStorefrontSurfaceLabels: StorefrontSurfaceLabels = {
54
+ add: 'Add',
55
+ available: 'available',
56
+ browse: 'Browse',
57
+ bundle: 'Bundle',
58
+ cart: 'Cart',
59
+ checkout: 'Checkout',
60
+ checkoutDisabled: 'Checkout is disabled in preview',
61
+ checkoutDisabledBadge: 'Checkout disabled',
62
+ demoBadge: 'Demo',
63
+ emptyCart: 'Add a listing to start checkout.',
64
+ emptyListingsDescription:
65
+ 'Publish a listing to make this storefront ready for buyers.',
66
+ emptyListingsTitle: 'No listings yet',
67
+ fallbackDescription: 'This listing is available for checkout.',
68
+ form: {
69
+ email: 'Email',
70
+ name: 'Name',
71
+ note: 'Order note',
72
+ phone: 'Phone',
73
+ },
74
+ privateStore: 'Private',
75
+ previewBadge: 'Preview',
76
+ product: 'Product',
77
+ publicStore: 'Public',
78
+ quantity: 'Qty',
79
+ reserve: 'Reserve with Polar',
80
+ reserving: 'Reserving...',
81
+ reservedCopy:
82
+ 'Review your cart, then continue with the available checkout mode.',
83
+ simulatedBadge: 'Simulated checkout',
84
+ soldOut: 'Sold out',
85
+ total: 'Total',
86
+ };
87
+
88
+ export function mergeStorefrontSurfaceLabels(
89
+ labels?: Partial<StorefrontSurfaceLabels>
90
+ ) {
91
+ return {
92
+ ...defaultStorefrontSurfaceLabels,
93
+ ...labels,
94
+ form: {
95
+ ...defaultStorefrontSurfaceLabels.form,
96
+ ...labels?.form,
97
+ },
98
+ };
99
+ }
@@ -0,0 +1,90 @@
1
+ import type {
2
+ InventoryStorefront,
3
+ InventoryStorefrontListing,
4
+ } from '@tuturuuu/internal-api/inventory';
5
+ import type { CSSProperties } from 'react';
6
+
7
+ export const storefrontRadiusClasses: Record<
8
+ InventoryStorefront['cornerStyle'],
9
+ string
10
+ > = {
11
+ compact: 'rounded-md',
12
+ rounded: 'rounded-lg',
13
+ soft: 'rounded-2xl',
14
+ };
15
+
16
+ export const storefrontSurfaceClasses: Record<
17
+ InventoryStorefront['surfaceStyle'],
18
+ string
19
+ > = {
20
+ glass:
21
+ 'border border-border/70 bg-card/75 shadow-sm shadow-foreground/5 backdrop-blur',
22
+ soft: 'border border-border/70 bg-muted/35 shadow-sm shadow-foreground/5',
23
+ solid: 'border border-border bg-card shadow-sm shadow-foreground/5',
24
+ };
25
+
26
+ export type StorefrontAccentStyle = CSSProperties & {
27
+ '--storefront-accent'?: string;
28
+ '--storefront-accent-foreground'?: string;
29
+ };
30
+
31
+ export function sanitizeStorefrontAccentColor(value?: string | null) {
32
+ const normalized = value?.trim();
33
+ if (!normalized) return null;
34
+
35
+ if (/^#[0-9a-f]{3}$/i.test(normalized)) {
36
+ const [, r, g, b] = normalized;
37
+ return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
38
+ }
39
+
40
+ if (/^#[0-9a-f]{6}$/i.test(normalized)) {
41
+ return normalized.toLowerCase();
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ export function getStorefrontListingLimit(listing: InventoryStorefrontListing) {
48
+ const available =
49
+ typeof listing.availableQuantity === 'number'
50
+ ? listing.availableQuantity
51
+ : Number.POSITIVE_INFINITY;
52
+
53
+ return Math.max(0, Math.min(listing.maxPerOrder, available));
54
+ }
55
+
56
+ export function formatStorefrontPrice(value: number, currency: string) {
57
+ return new Intl.NumberFormat(undefined, {
58
+ currency,
59
+ maximumFractionDigits: 0,
60
+ style: 'currency',
61
+ }).format(value);
62
+ }
63
+
64
+ export function getListingInitials(title: string) {
65
+ return title
66
+ .split(/\s+/)
67
+ .filter(Boolean)
68
+ .slice(0, 2)
69
+ .map((part) => part[0]?.toUpperCase())
70
+ .join('');
71
+ }
72
+
73
+ export function getAccentStyle(
74
+ accentColor: string | null
75
+ ): StorefrontAccentStyle {
76
+ if (!accentColor) return {};
77
+
78
+ return {
79
+ '--storefront-accent': accentColor,
80
+ '--storefront-accent-foreground': getAccentForeground(accentColor),
81
+ };
82
+ }
83
+
84
+ function getAccentForeground(hexColor: string) {
85
+ const red = Number.parseInt(hexColor.slice(1, 3), 16);
86
+ const green = Number.parseInt(hexColor.slice(3, 5), 16);
87
+ const blue = Number.parseInt(hexColor.slice(5, 7), 16);
88
+ const luminance = (0.299 * red + 0.587 * green + 0.114 * blue) / 255;
89
+ return luminance > 0.64 ? '#111111' : '#ffffff';
90
+ }
@@ -0,0 +1,134 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import { describe, expect, it } from 'vitest';
4
+ import {
5
+ getTaskCardHydratingOpenOptions,
6
+ isExternalTaskSnapshot,
7
+ } from './task-card-open-options';
8
+
9
+ const task = {
10
+ id: 'task-1',
11
+ name: 'Visible task',
12
+ description: '',
13
+ list_id: 'list-1',
14
+ display_number: 7,
15
+ created_at: '2026-06-12T00:00:00.000Z',
16
+ end_date: null,
17
+ priority: 'normal',
18
+ } satisfies Task;
19
+
20
+ const list = {
21
+ id: 'list-1',
22
+ name: 'To Do',
23
+ board_id: 'board-1',
24
+ position: 0,
25
+ status: 'not_started',
26
+ color: 'BLUE',
27
+ created_at: '2026-06-12T00:00:00.000Z',
28
+ creator_id: 'user-1',
29
+ archived: false,
30
+ deleted: false,
31
+ } satisfies TaskList;
32
+
33
+ describe('getTaskCardHydratingOpenOptions', () => {
34
+ it('opens external task cards from the visible snapshot while hydrating source details', () => {
35
+ const externalTask = {
36
+ ...task,
37
+ list_id: 'personal-list',
38
+ source_workspace_id: 'source-workspace',
39
+ source_workspace_name: 'Source workspace',
40
+ source_board_id: 'source-board',
41
+ source_board_name: 'Source board',
42
+ source_list_id: 'source-list',
43
+ source_list_name: 'Doing',
44
+ source_list_status: 'active',
45
+ ticket_prefix: 'SRC',
46
+ } satisfies Task & { ticket_prefix: string };
47
+
48
+ expect(
49
+ getTaskCardHydratingOpenOptions({
50
+ task: externalTask,
51
+ boardId: 'personal-board',
52
+ availableLists: [list],
53
+ effectiveWorkspaceId: 'personal-workspace',
54
+ isPersonalWorkspace: true,
55
+ })
56
+ ).toEqual({
57
+ initialTask: {
58
+ ...externalTask,
59
+ list_id: 'source-list',
60
+ },
61
+ boardId: 'source-board',
62
+ availableLists: [
63
+ {
64
+ id: 'source-list',
65
+ name: 'Doing',
66
+ board_id: 'source-board',
67
+ position: 0,
68
+ status: 'active',
69
+ color: 'GRAY',
70
+ created_at: '2026-06-12T00:00:00.000Z',
71
+ creator_id: '',
72
+ archived: false,
73
+ deleted: false,
74
+ },
75
+ ],
76
+ taskWsId: 'source-workspace',
77
+ taskWorkspacePersonal: false,
78
+ initialSharedContext: {
79
+ boardConfig: {
80
+ id: 'source-board',
81
+ name: 'Source board',
82
+ ws_id: 'source-workspace',
83
+ ticket_prefix: 'SRC',
84
+ },
85
+ availableLists: [
86
+ {
87
+ id: 'source-list',
88
+ name: 'Doing',
89
+ board_id: 'source-board',
90
+ position: 0,
91
+ status: 'active',
92
+ color: 'GRAY',
93
+ created_at: '2026-06-12T00:00:00.000Z',
94
+ creator_id: '',
95
+ archived: false,
96
+ deleted: false,
97
+ },
98
+ ],
99
+ workspaceLabels: [],
100
+ workspaceMembers: [],
101
+ workspaceProjects: [],
102
+ },
103
+ });
104
+ });
105
+
106
+ it('keeps local board metadata for non-source personal tasks', () => {
107
+ expect(
108
+ getTaskCardHydratingOpenOptions({
109
+ task,
110
+ boardId: 'board-1',
111
+ availableLists: [list],
112
+ effectiveWorkspaceId: 'workspace-1',
113
+ isPersonalWorkspace: true,
114
+ })
115
+ ).toEqual({
116
+ initialTask: task,
117
+ boardId: 'board-1',
118
+ availableLists: [list],
119
+ taskWsId: 'workspace-1',
120
+ taskWorkspacePersonal: true,
121
+ initialSharedContext: undefined,
122
+ });
123
+ });
124
+
125
+ it('treats source metadata as an external task snapshot', () => {
126
+ expect(
127
+ isExternalTaskSnapshot({
128
+ ...task,
129
+ source_workspace_id: 'source-workspace',
130
+ })
131
+ ).toBe(true);
132
+ expect(isExternalTaskSnapshot(task)).toBe(false);
133
+ });
134
+ });
@@ -0,0 +1,127 @@
1
+ import type { Task } from '@tuturuuu/types/primitives/Task';
2
+ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
3
+ import type { SharedTaskContext } from '../../../shared/task-edit-dialog/hooks/use-task-data';
4
+
5
+ interface TaskCardOpenOptionsInput {
6
+ task: Task;
7
+ boardId: string;
8
+ availableLists?: TaskList[];
9
+ effectiveWorkspaceId?: string;
10
+ isPersonalWorkspace: boolean;
11
+ }
12
+
13
+ export function isExternalTaskSnapshot(task: Task) {
14
+ return (
15
+ task.is_personal_external === true ||
16
+ Boolean(task.personal_board_id) ||
17
+ Boolean(task.source_workspace_id) ||
18
+ Boolean(task.source_board_id)
19
+ );
20
+ }
21
+
22
+ function normalizeSourceListStatus(status?: string | null): TaskList['status'] {
23
+ switch (status) {
24
+ case 'documents':
25
+ case 'not_started':
26
+ case 'active':
27
+ case 'review':
28
+ case 'done':
29
+ case 'closed':
30
+ return status;
31
+ default:
32
+ return 'not_started';
33
+ }
34
+ }
35
+
36
+ function getTaskTicketPrefix(task: Task) {
37
+ return 'ticket_prefix' in task && typeof task.ticket_prefix === 'string'
38
+ ? task.ticket_prefix
39
+ : undefined;
40
+ }
41
+
42
+ function buildInitialSourceList(
43
+ task: Task,
44
+ sourceBoardId: string
45
+ ): TaskList | undefined {
46
+ if (!task.source_list_id) {
47
+ return undefined;
48
+ }
49
+
50
+ return {
51
+ id: task.source_list_id,
52
+ name: task.source_list_name ?? task.source_list_id,
53
+ archived: false,
54
+ deleted: false,
55
+ created_at: task.created_at,
56
+ board_id: sourceBoardId,
57
+ creator_id: '',
58
+ status: normalizeSourceListStatus(task.source_list_status),
59
+ color: 'GRAY',
60
+ position: 0,
61
+ };
62
+ }
63
+
64
+ function buildInitialSourceContext(
65
+ task: Task,
66
+ sourceWorkspaceId?: string,
67
+ sourceBoardId?: string
68
+ ): SharedTaskContext | undefined {
69
+ if (!sourceBoardId) {
70
+ return undefined;
71
+ }
72
+
73
+ const sourceList = buildInitialSourceList(task, sourceBoardId);
74
+
75
+ return {
76
+ boardConfig: {
77
+ id: sourceBoardId,
78
+ name: task.source_board_name ?? sourceBoardId,
79
+ ws_id: sourceWorkspaceId,
80
+ ticket_prefix: getTaskTicketPrefix(task),
81
+ },
82
+ availableLists: sourceList ? [sourceList] : undefined,
83
+ workspaceLabels: task.labels ?? [],
84
+ workspaceMembers:
85
+ task.assignees?.map((assignee) => ({
86
+ id: assignee.id,
87
+ user_id: assignee.id,
88
+ display_name: assignee.display_name ?? assignee.email ?? assignee.id,
89
+ avatar_url: assignee.avatar_url ?? null,
90
+ })) ?? [],
91
+ workspaceProjects: task.projects ?? [],
92
+ };
93
+ }
94
+
95
+ export function getTaskCardHydratingOpenOptions({
96
+ task,
97
+ boardId,
98
+ availableLists,
99
+ effectiveWorkspaceId,
100
+ isPersonalWorkspace,
101
+ }: TaskCardOpenOptionsInput) {
102
+ const sourceWorkspaceId = task.source_workspace_id;
103
+ const sourceBoardId = task.source_board_id;
104
+ const initialSharedContext = buildInitialSourceContext(
105
+ task,
106
+ sourceWorkspaceId ?? undefined,
107
+ sourceBoardId ?? undefined
108
+ );
109
+ const initialTask = {
110
+ ...task,
111
+ list_id: task.source_list_id ?? task.list_id,
112
+ };
113
+
114
+ return {
115
+ initialTask,
116
+ boardId: sourceBoardId ?? boardId,
117
+ availableLists:
118
+ sourceBoardId && initialSharedContext?.availableLists
119
+ ? initialSharedContext.availableLists
120
+ : sourceBoardId
121
+ ? undefined
122
+ : availableLists,
123
+ taskWsId: sourceWorkspaceId ?? effectiveWorkspaceId,
124
+ taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
125
+ initialSharedContext,
126
+ };
127
+ }