@tuturuuu/ui 0.4.0 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -6
  3. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +62 -0
  4. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +8 -2
  5. package/src/components/ui/chat/chat-sidebar-conversation-groups.tsx +332 -0
  6. package/src/components/ui/chat/chat-sidebar-groups.test.ts +44 -0
  7. package/src/components/ui/chat/chat-sidebar-panel.test.tsx +2 -0
  8. package/src/components/ui/chat/chat-sidebar-panel.tsx +6 -0
  9. package/src/components/ui/chat/chat-sidebar-sections.ts +199 -0
  10. package/src/components/ui/chat/chat-sidebar.tsx +11 -258
  11. package/src/components/ui/chat/chat-workspace.tsx +5 -0
  12. package/src/components/ui/chat/utils.ts +7 -0
  13. package/src/components/ui/custom/settings/task-settings.tsx +76 -0
  14. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +126 -0
  15. package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +29 -18
  16. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  17. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  18. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  19. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +172 -0
  20. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  21. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +196 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +277 -0
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +189 -0
  27. package/src/components/ui/finance/wallets/query-invalidation.ts +2 -0
  28. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +7 -3
  29. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +10 -0
  30. package/src/components/ui/finance/wallets/wallets-page.test.tsx +7 -0
  31. package/src/components/ui/finance/wallets/wallets-page.tsx +21 -5
  32. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  33. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  34. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  35. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +10 -0
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +141 -0
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +208 -0
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  40. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +24 -0
  41. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +18 -3
  42. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  43. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  44. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  45. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +411 -323
  46. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  47. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  48. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  49. package/src/hooks/use-task-actions.ts +45 -0
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import '@testing-library/jest-dom/vitest';
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
7
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
8
+ import type { ReactNode } from 'react';
9
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { QuickSettingsPopover } from '../../tu-do/shared/task-edit-dialog/components/quick-settings-popover';
11
+ import { TaskSettings } from './task-settings';
12
+
13
+ const {
14
+ mockSetSoundEffectsEnabled,
15
+ mockUpdateUserConfigMutate,
16
+ mockConfigState,
17
+ } = vi.hoisted(() => ({
18
+ mockSetSoundEffectsEnabled: vi.fn(),
19
+ mockUpdateUserConfigMutate: vi.fn(),
20
+ mockConfigState: {
21
+ soundEffectsEnabled: true,
22
+ soundEffectsVolume: '35',
23
+ },
24
+ }));
25
+
26
+ vi.mock('next-intl', () => ({
27
+ useTranslations: () => (key: string) => key,
28
+ }));
29
+
30
+ vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
31
+ useUserBooleanConfig: (configId: string, defaultValue = false) => {
32
+ if (configId === 'TASK_SOUND_EFFECTS_ENABLED') {
33
+ return {
34
+ isLoading: false,
35
+ isPending: false,
36
+ setValue: mockSetSoundEffectsEnabled,
37
+ toggle: vi.fn(),
38
+ value: mockConfigState.soundEffectsEnabled,
39
+ };
40
+ }
41
+
42
+ return {
43
+ isLoading: false,
44
+ isPending: false,
45
+ setValue: vi.fn(),
46
+ toggle: vi.fn(),
47
+ value: defaultValue,
48
+ };
49
+ },
50
+ useUserConfig: (configId: string, defaultValue = '') => ({
51
+ data:
52
+ configId === 'TASK_SOUND_EFFECTS_VOLUME'
53
+ ? mockConfigState.soundEffectsVolume
54
+ : defaultValue,
55
+ isLoading: false,
56
+ }),
57
+ useUpdateUserConfig: () => ({
58
+ isPending: false,
59
+ mutate: mockUpdateUserConfigMutate,
60
+ }),
61
+ }));
62
+
63
+ function renderWithQueryClient(children: ReactNode) {
64
+ const queryClient = new QueryClient({
65
+ defaultOptions: {
66
+ mutations: { retry: false },
67
+ queries: { retry: false },
68
+ },
69
+ });
70
+
71
+ return render(
72
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
73
+ );
74
+ }
75
+
76
+ describe('task sound settings controls', () => {
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ mockConfigState.soundEffectsEnabled = true;
80
+ mockConfigState.soundEffectsVolume = '35';
81
+ vi.stubGlobal(
82
+ 'fetch',
83
+ vi.fn(() =>
84
+ Promise.resolve({
85
+ json: () =>
86
+ Promise.resolve({
87
+ fade_completed_tasks: false,
88
+ task_auto_assign_to_self: false,
89
+ }),
90
+ ok: true,
91
+ })
92
+ )
93
+ );
94
+ });
95
+
96
+ it('renders task settings sound controls and persists the switch value', async () => {
97
+ renderWithQueryClient(<TaskSettings />);
98
+
99
+ expect(await screen.findByText('sound_effects')).toBeInTheDocument();
100
+ expect(screen.getByText('sound_effects_volume')).toBeInTheDocument();
101
+
102
+ fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
103
+
104
+ expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
105
+ });
106
+
107
+ it('renders the quick settings sound switch and persists changes', async () => {
108
+ renderWithQueryClient(<QuickSettingsPopover />);
109
+
110
+ const trigger = screen.getByRole('button', { name: 'Quick Settings' });
111
+
112
+ await waitFor(() => {
113
+ expect(trigger).not.toBeDisabled();
114
+ });
115
+
116
+ fireEvent.click(trigger);
117
+
118
+ await waitFor(() => {
119
+ expect(screen.getByText('sound_effects')).toBeInTheDocument();
120
+ });
121
+
122
+ fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
123
+
124
+ expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
125
+ });
126
+ });
@@ -73,6 +73,7 @@ import {
73
73
  import { PeriodBreakdownPanel } from './period-charts';
74
74
  import { TransactionCard } from './transaction-card';
75
75
  import { TransactionStatistics } from './transaction-statistics';
76
+ import { mergeLinkedTransferTransactions } from './transfer-merge';
76
77
 
77
78
  interface InfiniteTransactionsListProps {
78
79
  wsId: string;
@@ -446,6 +447,10 @@ export function InfiniteTransactionsList({
446
447
  const allTransactions = usePeriods
447
448
  ? []
448
449
  : dailyData?.pages.flatMap((page) => page.data) || [];
450
+ const visibleTransactions = useMemo(
451
+ () => mergeLinkedTransferTransactions(allTransactions),
452
+ [allTransactions]
453
+ );
449
454
 
450
455
  // All periods for period-based views
451
456
  const allPeriods: TransactionPeriod[] = usePeriods
@@ -485,26 +490,32 @@ export function InfiniteTransactionsList({
485
490
  const groupedTransactions = useMemo(() => {
486
491
  // For period-based views, convert periods to grouped transactions format
487
492
  if (usePeriods) {
488
- return allPeriods.map((period) => ({
489
- date: period.periodStart,
490
- label: generatePeriodLabel(period.periodStart, viewMode),
491
- transactions: period.transactions || [],
492
- // Store period stats for display
493
- periodStats: {
494
- totalIncome: period.totalIncome,
495
- totalExpense: period.totalExpense,
496
- netTotal: period.netTotal,
497
- transactionCount: period.transactionCount,
498
- hasRedactedAmounts: period.hasRedactedAmounts,
499
- },
500
- }));
493
+ return allPeriods.map((period) => {
494
+ const transactions = mergeLinkedTransferTransactions(
495
+ period.transactions || []
496
+ );
497
+
498
+ return {
499
+ date: period.periodStart,
500
+ label: generatePeriodLabel(period.periodStart, viewMode),
501
+ transactions,
502
+ // Store period stats for display
503
+ periodStats: {
504
+ totalIncome: period.totalIncome,
505
+ totalExpense: period.totalExpense,
506
+ netTotal: period.netTotal,
507
+ transactionCount: transactions.length,
508
+ hasRedactedAmounts: period.hasRedactedAmounts,
509
+ },
510
+ };
511
+ });
501
512
  }
502
513
 
503
514
  // For daily view, group transactions by date in the user's timezone
504
515
  const groups: GroupedTransactions[] = [];
505
516
  const now = dayjs().tz(resolvedTimezone);
506
517
 
507
- allTransactions.forEach((transaction) => {
518
+ visibleTransactions.forEach((transaction) => {
508
519
  // Parse transaction date in the user's timezone
509
520
  const transactionDate = dayjs(transaction.taken_at).tz(resolvedTimezone);
510
521
  const dateKey = transactionDate.format('YYYY-MM-DD');
@@ -543,7 +554,7 @@ export function InfiniteTransactionsList({
543
554
 
544
555
  return groups;
545
556
  }, [
546
- allTransactions,
557
+ visibleTransactions,
547
558
  allPeriods,
548
559
  usePeriods,
549
560
  viewMode,
@@ -604,7 +615,7 @@ export function InfiniteTransactionsList({
604
615
  // Check if there's no data (either no transactions for daily or no periods for other views)
605
616
  const hasNoData = usePeriods
606
617
  ? allPeriods.length === 0
607
- : allTransactions.length === 0;
618
+ : visibleTransactions.length === 0;
608
619
 
609
620
  if (hasNoData) {
610
621
  return (
@@ -627,7 +638,7 @@ export function InfiniteTransactionsList({
627
638
  {/* Statistics Summary - Only show when filters are active (daily view only uses transactions for stats) */}
628
639
  {hasActiveFilter && (stats || isStatsLoading) && !usePeriods && (
629
640
  <TransactionStatistics
630
- transactions={allTransactions}
641
+ transactions={visibleTransactions}
631
642
  stats={stats}
632
643
  isLoading={isStatsLoading}
633
644
  currency={currency}
@@ -1023,7 +1034,7 @@ export function InfiniteTransactionsList({
1023
1034
  {!hasNextPage &&
1024
1035
  (usePeriods
1025
1036
  ? allPeriods.length > 5
1026
- : allTransactions.length > 10) && (
1037
+ : visibleTransactions.length > 10) && (
1027
1038
  <div className="rounded-xl border border-dashed bg-muted/20 p-6 text-center">
1028
1039
  <p className="text-muted-foreground text-sm">
1029
1040
  {t('user-data-table.common.end_of_list')}
@@ -83,35 +83,8 @@ export function TransactionCard({
83
83
  const [isHovered, setIsHovered] = useState(false);
84
84
  const { isConfidential: areNumbersHidden } =
85
85
  useFinanceConfidentialVisibility();
86
- const effectiveCurrency = transaction.wallet_currency || currency;
87
- const isExpense = (transaction.amount || 0) < 0;
88
86
  const isTransfer = !!transaction.transfer;
89
87
 
90
- // Currency conversion for foreign-currency transactions
91
- const isForeignCurrency =
92
- effectiveCurrency.toUpperCase() !== currency.toUpperCase();
93
- const { data: exchangeRateData } = useExchangeRates();
94
- const convertedAmount = useMemo(() => {
95
- if (
96
- !isForeignCurrency ||
97
- transaction.amount == null ||
98
- !exchangeRateData?.data
99
- )
100
- return null;
101
- return convertCurrency(
102
- transaction.amount,
103
- effectiveCurrency,
104
- currency,
105
- exchangeRateData.data
106
- );
107
- }, [
108
- isForeignCurrency,
109
- transaction.amount,
110
- effectiveCurrency,
111
- currency,
112
- exchangeRateData?.data,
113
- ]);
114
-
115
88
  // Check if transaction is confidential
116
89
  const isConfidential =
117
90
  transaction.is_amount_confidential ||
@@ -154,6 +127,65 @@ export function TransactionCard({
154
127
  return wallets.find((w) => w.id === transaction.transfer?.linked_wallet_id);
155
128
  }, [transaction.transfer?.linked_wallet_id, wallets]);
156
129
 
130
+ const transferDisplay = transaction.transfer
131
+ ? transaction.transfer.is_origin
132
+ ? {
133
+ amount: transaction.amount,
134
+ amountCurrency: transaction.wallet_currency,
135
+ destinationIcon: linkedWallet?.icon,
136
+ destinationImageSrc: linkedWallet?.image_src,
137
+ destinationWalletId: transaction.transfer.linked_wallet_id,
138
+ destinationWalletName: transaction.transfer.linked_wallet_name,
139
+ originIcon: wallet?.icon,
140
+ originImageSrc: wallet?.image_src,
141
+ originWalletId: transaction.wallet_id,
142
+ originWalletName: transaction.wallet,
143
+ secondaryAmount: transaction.transfer.linked_amount,
144
+ secondaryCurrency: transaction.transfer.linked_wallet_currency,
145
+ }
146
+ : {
147
+ amount: transaction.transfer.linked_amount ?? transaction.amount,
148
+ amountCurrency:
149
+ transaction.transfer.linked_wallet_currency ||
150
+ transaction.wallet_currency,
151
+ destinationIcon: wallet?.icon,
152
+ destinationImageSrc: wallet?.image_src,
153
+ destinationWalletId: transaction.wallet_id,
154
+ destinationWalletName: transaction.wallet,
155
+ originIcon: linkedWallet?.icon,
156
+ originImageSrc: linkedWallet?.image_src,
157
+ originWalletId: transaction.transfer.linked_wallet_id,
158
+ originWalletName: transaction.transfer.linked_wallet_name,
159
+ secondaryAmount: transaction.amount,
160
+ secondaryCurrency: transaction.wallet_currency,
161
+ }
162
+ : null;
163
+ const displayAmount = transferDisplay?.amount ?? transaction.amount;
164
+ const effectiveCurrency =
165
+ transferDisplay?.amountCurrency || transaction.wallet_currency || currency;
166
+ const isExpense = (displayAmount || 0) < 0;
167
+
168
+ // Currency conversion for foreign-currency transactions
169
+ const isForeignCurrency =
170
+ effectiveCurrency.toUpperCase() !== currency.toUpperCase();
171
+ const { data: exchangeRateData } = useExchangeRates();
172
+ const convertedAmount = useMemo(() => {
173
+ if (!isForeignCurrency || displayAmount == null || !exchangeRateData?.data)
174
+ return null;
175
+ return convertCurrency(
176
+ displayAmount,
177
+ effectiveCurrency,
178
+ currency,
179
+ exchangeRateData.data
180
+ );
181
+ }, [
182
+ isForeignCurrency,
183
+ displayAmount,
184
+ effectiveCurrency,
185
+ currency,
186
+ exchangeRateData?.data,
187
+ ]);
188
+
157
189
  // Determine if we should use custom styling
158
190
  const hasCustomStyling = Boolean(customColorStyles);
159
191
 
@@ -331,37 +363,37 @@ export function TransactionCard({
331
363
  {transaction.category}
332
364
  </Badge>
333
365
  ) : null}
334
- {transaction.wallet && (
366
+ {(transaction.wallet || transferDisplay) && (
335
367
  <div className="flex items-center gap-1">
336
- {isTransfer && transaction.transfer ? (
368
+ {isTransfer && transferDisplay ? (
337
369
  <div className="flex items-center rounded-full border border-dynamic-blue/20 bg-dynamic-blue/5 py-0.5 pr-1 pl-1">
338
370
  <Link
339
- href={`/${wsId}${financeHref(`/wallets/${transaction.wallet_id}`)}`}
371
+ href={`/${wsId}${financeHref(`/wallets/${transferDisplay.originWalletId}`)}`}
340
372
  onClick={(e) => e.stopPropagation()}
341
373
  >
342
374
  <span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-muted-foreground transition-colors hover:bg-dynamic-blue/10 hover:text-foreground sm:text-xs">
343
375
  <WalletIconDisplay
344
- icon={wallet?.icon}
345
- imageSrc={wallet?.image_src}
376
+ icon={transferDisplay.originIcon}
377
+ imageSrc={transferDisplay.originImageSrc}
346
378
  size="sm"
347
379
  className="h-3 w-3"
348
380
  />
349
- {transaction.wallet}
381
+ {transferDisplay.originWalletName}
350
382
  </span>
351
383
  </Link>
352
384
  <ArrowRight className="mx-0.5 h-3 w-3 shrink-0 text-dynamic-blue" />
353
385
  <Link
354
- href={`/${wsId}${financeHref(`/wallets/${transaction.transfer.linked_wallet_id}`)}`}
386
+ href={`/${wsId}${financeHref(`/wallets/${transferDisplay.destinationWalletId}`)}`}
355
387
  onClick={(e) => e.stopPropagation()}
356
388
  >
357
389
  <span className="flex items-center gap-1 rounded-full px-1.5 py-0.5 font-medium text-[11px] text-dynamic-blue transition-colors hover:bg-dynamic-blue/10 sm:text-xs">
358
390
  <WalletIconDisplay
359
- icon={linkedWallet?.icon}
360
- imageSrc={linkedWallet?.image_src}
391
+ icon={transferDisplay.destinationIcon}
392
+ imageSrc={transferDisplay.destinationImageSrc}
361
393
  size="sm"
362
394
  className="h-3 w-3"
363
395
  />
364
- {transaction.transfer.linked_wallet_name}
396
+ {transferDisplay.destinationWalletName}
365
397
  </span>
366
398
  </Link>
367
399
  </div>
@@ -401,7 +433,7 @@ export function TransactionCard({
401
433
  <div className="flex shrink-0 items-center gap-1 sm:gap-2">
402
434
  <div className="flex flex-col items-end">
403
435
  <ConfidentialAmount
404
- amount={transaction.amount ?? null}
436
+ amount={displayAmount ?? null}
405
437
  isConfidential={transaction.is_amount_confidential || false}
406
438
  currency={effectiveCurrency}
407
439
  className={cn(
@@ -426,16 +458,16 @@ export function TransactionCard({
426
458
  }
427
459
  />
428
460
  {isTransfer &&
429
- transaction.transfer?.linked_amount != null &&
430
- transaction.transfer?.linked_wallet_currency &&
431
- transaction.transfer.linked_wallet_currency.toUpperCase() !==
461
+ transferDisplay?.secondaryAmount != null &&
462
+ transferDisplay.secondaryCurrency &&
463
+ transferDisplay.secondaryCurrency.toUpperCase() !==
432
464
  effectiveCurrency.toUpperCase() && (
433
465
  <span className="text-[10px] text-dynamic-blue tabular-nums sm:text-xs">
434
466
  {areNumbersHidden
435
467
  ? FINANCE_HIDDEN_AMOUNT
436
468
  : formatCurrency(
437
- Math.abs(transaction.transfer.linked_amount),
438
- transaction.transfer.linked_wallet_currency,
469
+ Math.abs(transferDisplay.secondaryAmount),
470
+ transferDisplay.secondaryCurrency,
439
471
  undefined,
440
472
  { signDisplay: 'always' }
441
473
  )}
@@ -0,0 +1,90 @@
1
+ import type { Transaction } from '@tuturuuu/types/primitives/Transaction';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { mergeLinkedTransferTransactions } from './transfer-merge';
4
+
5
+ function transaction(
6
+ id: string,
7
+ amount: number,
8
+ transfer?: Transaction['transfer']
9
+ ): Transaction {
10
+ return {
11
+ amount,
12
+ id,
13
+ taken_at: '2026-06-11T00:00:00.000Z',
14
+ wallet_id: `wallet-${id}`,
15
+ wallet: `Wallet ${id}`,
16
+ transfer,
17
+ };
18
+ }
19
+
20
+ const originTransfer = {
21
+ is_origin: true,
22
+ linked_amount: 100,
23
+ linked_transaction_id: 'destination',
24
+ linked_wallet_id: 'wallet-destination',
25
+ linked_wallet_name: 'Destination Wallet',
26
+ } satisfies Transaction['transfer'];
27
+
28
+ const destinationTransfer = {
29
+ is_origin: false,
30
+ linked_amount: -100,
31
+ linked_transaction_id: 'origin',
32
+ linked_wallet_id: 'wallet-origin',
33
+ linked_wallet_name: 'Origin Wallet',
34
+ } satisfies Transaction['transfer'];
35
+
36
+ describe('mergeLinkedTransferTransactions', () => {
37
+ it('collapses a linked origin and destination pair into one transfer row', () => {
38
+ const origin = transaction('origin', -100, originTransfer);
39
+ const destination = transaction('destination', 100, destinationTransfer);
40
+
41
+ expect(mergeLinkedTransferTransactions([origin, destination])).toEqual([
42
+ origin,
43
+ ]);
44
+ });
45
+
46
+ it('keeps the origin transfer when the destination leg appears first', () => {
47
+ const origin = transaction('origin', -100, originTransfer);
48
+ const destination = transaction('destination', 100, destinationTransfer);
49
+
50
+ expect(mergeLinkedTransferTransactions([destination, origin])).toEqual([
51
+ origin,
52
+ ]);
53
+ });
54
+
55
+ it('keeps an unmatched transfer leg visible', () => {
56
+ const destination = transaction('destination', 100, destinationTransfer);
57
+
58
+ expect(mergeLinkedTransferTransactions([destination])).toEqual([
59
+ destination,
60
+ ]);
61
+ });
62
+
63
+ it('does not merge unrelated rows with matching amounts', () => {
64
+ const expense = transaction('expense', -100);
65
+ const income = transaction('income', 100);
66
+
67
+ expect(mergeLinkedTransferTransactions([expense, income])).toEqual([
68
+ expense,
69
+ income,
70
+ ]);
71
+ });
72
+
73
+ it('keeps unrelated row order while using the first pair slot', () => {
74
+ const first = transaction('first', 1);
75
+ const second = transaction('second', 2);
76
+ const third = transaction('third', 3);
77
+ const origin = transaction('origin', -100, originTransfer);
78
+ const destination = transaction('destination', 100, destinationTransfer);
79
+
80
+ expect(
81
+ mergeLinkedTransferTransactions([
82
+ first,
83
+ destination,
84
+ second,
85
+ origin,
86
+ third,
87
+ ])
88
+ ).toEqual([first, origin, second, third]);
89
+ });
90
+ });
@@ -0,0 +1,52 @@
1
+ import type { Transaction } from '@tuturuuu/types/primitives/Transaction';
2
+
3
+ function getTransferPairKey(transaction: Transaction) {
4
+ const transactionId = transaction.id;
5
+ const linkedTransactionId = transaction.transfer?.linked_transaction_id;
6
+
7
+ if (!transactionId || !linkedTransactionId) return null;
8
+
9
+ return [transactionId, linkedTransactionId].sort().join(':');
10
+ }
11
+
12
+ function preferOriginTransfer(
13
+ current: Transaction,
14
+ candidate: Transaction
15
+ ): Transaction {
16
+ if (candidate.transfer?.is_origin && !current.transfer?.is_origin) {
17
+ return candidate;
18
+ }
19
+
20
+ return current;
21
+ }
22
+
23
+ export function mergeLinkedTransferTransactions(
24
+ transactions: Transaction[]
25
+ ): Transaction[] {
26
+ const mergedTransactions: Transaction[] = [];
27
+ const pairSlotByKey = new Map<string, number>();
28
+
29
+ for (const transaction of transactions) {
30
+ const pairKey = getTransferPairKey(transaction);
31
+
32
+ if (!pairKey) {
33
+ mergedTransactions.push(transaction);
34
+ continue;
35
+ }
36
+
37
+ const existingSlot = pairSlotByKey.get(pairKey);
38
+
39
+ if (existingSlot === undefined) {
40
+ pairSlotByKey.set(pairKey, mergedTransactions.length);
41
+ mergedTransactions.push(transaction);
42
+ continue;
43
+ }
44
+
45
+ mergedTransactions[existingSlot] = preferOriginTransfer(
46
+ mergedTransactions[existingSlot]!,
47
+ transaction
48
+ );
49
+ }
50
+
51
+ return mergedTransactions;
52
+ }