@tuturuuu/ui 0.4.1 → 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 (107) hide show
  1. package/CHANGELOG.md +43 -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 +126 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +146 -0
  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/infinite-transactions-list.tsx +29 -18
  22. package/src/components/ui/finance/transactions/transaction-card.tsx +75 -43
  23. package/src/components/ui/finance/transactions/transfer-merge.test.ts +90 -0
  24. package/src/components/ui/finance/transactions/transfer-merge.ts +52 -0
  25. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +219 -0
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-amount.tsx +32 -0
  28. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-delete-dialog.tsx +50 -0
  29. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-dialog.tsx +138 -0
  30. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  31. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +197 -0
  32. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +201 -0
  33. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +541 -0
  34. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +362 -0
  35. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  36. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  37. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  38. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  39. package/src/components/ui/finance/wallets/form.tsx +41 -197
  40. package/src/components/ui/finance/wallets/query-invalidation.ts +3 -0
  41. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  42. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  43. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  44. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  45. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  46. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  47. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  48. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +71 -5
  49. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +52 -35
  50. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  51. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  52. package/src/components/ui/finance/wallets/wallets-page.test.tsx +117 -36
  53. package/src/components/ui/finance/wallets/wallets-page.tsx +40 -64
  54. package/src/components/ui/storefront/accent-button.tsx +33 -0
  55. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  56. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  57. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  58. package/src/components/ui/storefront/image-panel.tsx +40 -0
  59. package/src/components/ui/storefront/index.ts +12 -0
  60. package/src/components/ui/storefront/listing-card.tsx +129 -0
  61. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  62. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  63. package/src/components/ui/storefront/types.ts +99 -0
  64. package/src/components/ui/storefront/utils.ts +90 -0
  65. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +14 -0
  66. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operations.ts +29 -0
  67. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  68. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  69. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  70. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  71. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  72. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  73. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +11 -0
  74. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  75. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +124 -7
  76. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  77. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  78. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  79. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  80. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  81. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  82. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  83. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +268 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +243 -0
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +26 -0
  87. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  88. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  89. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  90. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  91. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +36 -20
  92. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +41 -1
  93. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +157 -102
  94. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  95. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  96. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.test.ts +84 -1
  97. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +5 -1
  98. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  99. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +300 -172
  100. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +959 -340
  101. package/src/components/ui/tu-do/shared/task-sound-effects.test.ts +189 -0
  102. package/src/components/ui/tu-do/shared/task-sound-effects.tsx +468 -0
  103. package/src/hooks/__tests__/use-task-actions.test.tsx +61 -0
  104. package/src/hooks/use-task-actions.ts +45 -0
  105. package/src/hooks/useBoardRealtime.ts +54 -1
  106. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  107. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -14,7 +14,6 @@ import {
14
14
  CommandSeparator,
15
15
  } from '../command';
16
16
  import { Popover, PopoverContent, PopoverTrigger } from '../popover';
17
- import { Separator } from '../separator';
18
17
 
19
18
  export type ComboboxOption = {
20
19
  value: string;
@@ -36,6 +35,8 @@ export type ComboboxAction = {
36
35
  disabled?: boolean;
37
36
  };
38
37
 
38
+ export type ComboboxCreateResult = string | ComboboxOption | undefined;
39
+
39
40
  /** @deprecated Use ComboboxOption instead */
40
41
  export type ComboboxOptions = ComboboxOption;
41
42
 
@@ -62,6 +63,8 @@ interface ComboboxProps {
62
63
  emptyText?: string;
63
64
  /** Text to display when creating a new item (used with onCreate) */
64
65
  createText?: string;
66
+ /** Text to display while a new item is being created */
67
+ creatingText?: string;
65
68
  /** Override label shown on the trigger button */
66
69
  label?: React.ReactNode;
67
70
  /** Additional class name for the container */
@@ -71,7 +74,7 @@ interface ComboboxProps {
71
74
  /** Whether to select the first option by default */
72
75
  useFirstValueAsDefault?: boolean;
73
76
  /** Callback to create a new option from the search query */
74
- onCreate?: (value: string) => void;
77
+ onCreate?: (value: string) => unknown | Promise<unknown>;
75
78
  /** Callback when search input changes */
76
79
  onSearchChange?: (value: string) => void;
77
80
  /** Whether there are more options to load */
@@ -102,6 +105,7 @@ export function Combobox({
102
105
  searchPlaceholder,
103
106
  emptyText,
104
107
  createText,
108
+ creatingText,
105
109
  label,
106
110
  className,
107
111
  disabled,
@@ -117,14 +121,31 @@ export function Combobox({
117
121
  }: ComboboxProps) {
118
122
  const [open, setOpen] = React.useState(false);
119
123
  const [query, setQuery] = React.useState<string>('');
124
+ const [creating, setCreating] = React.useState(false);
125
+ const createInFlightRef = React.useRef(false);
120
126
  const actionValuePrefix = '__combobox_action__';
127
+ const createValuePrefix = '__combobox_create__';
121
128
 
122
129
  // Resolve text with fallbacks: explicit prop > t function > default
123
130
  const resolvedEmptyText =
124
131
  emptyText ?? t?.('common.empty') ?? 'No results found.';
125
132
  const resolvedCreateText = createText ?? t?.('common.add') ?? 'Create';
133
+ const resolvedCreatingText =
134
+ creatingText ?? t?.('common.creating') ?? 'Creating...';
126
135
  const resolvedSearchPlaceholder =
127
136
  searchPlaceholder ?? placeholder ?? 'Search...';
137
+ const normalizedQuery = normalizeComboboxText(query);
138
+ const trimmedQuery = query.trim();
139
+ const hasExactQueryMatch = React.useMemo(() => {
140
+ if (!normalizedQuery) return false;
141
+
142
+ return options.some((option) =>
143
+ [option.label, option.value, option.searchValue]
144
+ .filter((value): value is string => typeof value === 'string')
145
+ .some((value) => normalizeComboboxText(value) === normalizedQuery)
146
+ );
147
+ }, [normalizedQuery, options]);
148
+ const canCreate = Boolean(onCreate && normalizedQuery && !hasExactQueryMatch);
128
149
 
129
150
  React.useEffect(() => {
130
151
  if (!open) {
@@ -206,8 +227,55 @@ export function Combobox({
206
227
  );
207
228
  };
208
229
 
230
+ const commitCreatedValue = React.useCallback(
231
+ (result: unknown) => {
232
+ if (!onChange) return;
233
+
234
+ const createdValue =
235
+ typeof result === 'string'
236
+ ? result
237
+ : result &&
238
+ typeof result === 'object' &&
239
+ 'value' in result &&
240
+ typeof result.value === 'string'
241
+ ? result.value
242
+ : undefined;
243
+
244
+ if (!createdValue) return;
245
+
246
+ if (mode === 'multiple' && Array.isArray(selected)) {
247
+ if (!selected.includes(createdValue)) {
248
+ onChange([...selected, createdValue]);
249
+ }
250
+ return;
251
+ }
252
+
253
+ onChange(createdValue);
254
+ },
255
+ [mode, onChange, selected]
256
+ );
257
+
258
+ const handleCreate = React.useCallback(async () => {
259
+ if (!onCreate || !trimmedQuery || createInFlightRef.current) return;
260
+
261
+ createInFlightRef.current = true;
262
+ setCreating(true);
263
+
264
+ try {
265
+ const result = await onCreate(trimmedQuery);
266
+ commitCreatedValue(result);
267
+ setOpen(false);
268
+ setQuery('');
269
+ } catch {
270
+ // Keep the popover open so callers can surface their own error UI.
271
+ } finally {
272
+ createInFlightRef.current = false;
273
+ setCreating(false);
274
+ }
275
+ }, [commitCreatedValue, onCreate, trimmedQuery]);
276
+
209
277
  return (
210
- <div className={cn('block', className)}>
278
+ <div className={cn('block min-w-0', className)}>
211
279
  <Popover open={open} onOpenChange={setOpen} modal={true}>
212
280
  <PopoverTrigger asChild>
213
281
  <Button
@@ -216,7 +284,7 @@ export function Combobox({
216
284
  role="combobox"
217
285
  aria-expanded={open}
218
286
  className={cn(
219
- 'w-full justify-between',
287
+ 'w-full min-w-0 justify-between overflow-hidden',
220
288
  !selectedLabel && 'text-muted-foreground'
221
289
  )}
222
290
  disabled={disabled}
@@ -254,7 +322,7 @@ export function Combobox({
254
322
  : selectedOption.icon}
255
323
  </span>
256
324
  )}
257
- <span className="min-w-0 flex-1">
325
+ <span className="min-w-0 flex-1 truncate text-left">
258
326
  {label ? (
259
327
  label
260
328
  ) : (
@@ -281,13 +349,14 @@ export function Combobox({
281
349
  </Button>
282
350
  </PopoverTrigger>
283
351
  <PopoverContent
284
- className="z-9999 w-(--radix-popover-trigger-width) p-0"
352
+ className="z-9999 w-(--radix-popover-trigger-width) max-w-[calc(100vw-2rem)] p-0"
285
353
  align="start"
286
354
  sideOffset={4}
287
355
  >
288
356
  <Command
289
357
  filter={(value, search) => {
290
358
  if (value.startsWith(actionValuePrefix)) return 1;
359
+ if (value.startsWith(createValuePrefix)) return 1;
291
360
  if (value.toLowerCase().includes(search.toLowerCase())) return 1;
292
361
  return 0;
293
362
  }}
@@ -300,38 +369,11 @@ export function Combobox({
300
369
  onSearchChange?.(value);
301
370
  }}
302
371
  />
303
- <CommandEmpty className="flex flex-col items-center justify-center p-1">
304
- <div className="p-8 text-muted-foreground text-sm">
305
- {resolvedEmptyText}
306
- </div>
307
- {onCreate && (
308
- <>
309
- <Separator />
310
- <Button
311
- variant="ghost"
312
- className="mt-1 w-full"
313
- onClick={() => {
314
- if (onCreate) {
315
- onCreate(query);
316
- setOpen(false);
317
- setQuery('');
318
- }
319
- }}
320
- disabled={!query || !onCreate}
321
- >
322
- <Plus className="mr-2 h-4 w-4 shrink-0" />
323
- <div className="w-full truncate">
324
- <span className="font-normal">{resolvedCreateText}</span>{' '}
325
- <span className="underline decoration-dashed underline-offset-2">
326
- {query}
327
- </span>
328
- </div>
329
- </Button>
330
- </>
331
- )}
372
+ <CommandEmpty className="p-8 text-center text-muted-foreground text-sm">
373
+ {resolvedEmptyText}
332
374
  </CommandEmpty>
333
375
  <CommandList
334
- className="max-h-50 overflow-y-auto overscroll-contain"
376
+ className="max-h-[min(20rem,calc(100dvh-8rem))] overflow-y-auto overscroll-contain"
335
377
  onScroll={handleListScroll}
336
378
  style={
337
379
  {
@@ -346,6 +388,29 @@ export function Combobox({
346
388
  <CommandSeparator />
347
389
  </>
348
390
  ) : null}
391
+ {canCreate ? (
392
+ <>
393
+ <CommandGroup>
394
+ <CommandItem
395
+ value={`${createValuePrefix}:${trimmedQuery}`}
396
+ disabled={creating}
397
+ onSelect={handleCreate}
398
+ className="font-medium text-primary [&_svg]:text-primary"
399
+ >
400
+ <Plus className="h-4 w-4 shrink-0" />
401
+ <span className="min-w-0 flex-1 truncate">
402
+ <span className="font-normal">
403
+ {creating ? resolvedCreatingText : resolvedCreateText}
404
+ </span>{' '}
405
+ <span className="underline decoration-dashed underline-offset-2">
406
+ {trimmedQuery}
407
+ </span>
408
+ </span>
409
+ </CommandItem>
410
+ </CommandGroup>
411
+ <CommandSeparator />
412
+ </>
413
+ ) : null}
349
414
  <CommandGroup>
350
415
  {options.map((option) => (
351
416
  <CommandItem
@@ -463,3 +528,7 @@ export function Combobox({
463
528
  </div>
464
529
  );
465
530
  }
531
+
532
+ function normalizeComboboxText(value: string) {
533
+ return value.trim().toLocaleLowerCase();
534
+ }
@@ -20,7 +20,18 @@ import { toast } from '@tuturuuu/ui/sonner';
20
20
  import { Switch } from '@tuturuuu/ui/switch';
21
21
  import { useTranslations } from 'next-intl';
22
22
  import { useEffect } from 'react';
23
+ import {
24
+ normalizeTaskDialogPresentation,
25
+ TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
26
+ type TaskDialogPresentation,
27
+ } from '../../tu-do/shared/task-dialog-presentation';
23
28
  import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
29
+ import {
30
+ clampTaskSoundEffectsVolume,
31
+ DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
32
+ TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID,
33
+ TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
34
+ } from '../../tu-do/shared/task-sound-effects';
24
35
 
25
36
  interface TaskSettingsData {
26
37
  task_auto_assign_to_self: boolean;
@@ -74,10 +85,25 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
74
85
  isLoading: showReviewDueDatesLoading,
75
86
  isPending: showReviewDueDatesPending,
76
87
  } = useUserBooleanConfig(TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID, false);
88
+ const {
89
+ value: soundEffectsEnabled,
90
+ setValue: setSoundEffectsEnabled,
91
+ isLoading: soundEffectsEnabledLoading,
92
+ isPending: soundEffectsEnabledPending,
93
+ } = useUserBooleanConfig(TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID, true);
94
+ const { data: soundEffectsVolume, isLoading: soundEffectsVolumeLoading } =
95
+ useUserConfig(
96
+ TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
97
+ String(DEFAULT_TASK_SOUND_EFFECTS_VOLUME)
98
+ );
99
+ const updateSoundEffectsVolume = useUpdateUserConfig();
77
100
 
78
101
  const { data: submitShortcut, isLoading: submitShortcutLoading } =
79
102
  useUserConfig('TASK_SUBMIT_SHORTCUT', 'enter');
80
103
  const updateSubmitShortcut = useUpdateUserConfig();
104
+ const { data: dialogPresentationRaw, isLoading: dialogPresentationLoading } =
105
+ useUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID, 'compact');
106
+ const updateDialogPresentation = useUpdateUserConfig();
81
107
 
82
108
  const { data: settings, isLoading } = useQuery({
83
109
  queryKey: ['user-task-settings'],
@@ -128,9 +154,33 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
128
154
  updateSettings.mutate({ fade_completed_tasks: checked });
129
155
  };
130
156
 
157
+ const handleSoundEffectsVolumeChange = (value: string) => {
158
+ updateSoundEffectsVolume.mutate({
159
+ configId: TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
160
+ value: String(clampTaskSoundEffectsVolume(value)),
161
+ });
162
+ };
163
+
131
164
  const effectiveAutoAssignValue = isPersonalWorkspace
132
165
  ? true
133
166
  : (settings?.task_auto_assign_to_self ?? false);
167
+ const normalizedSoundEffectsVolume = String(
168
+ clampTaskSoundEffectsVolume(soundEffectsVolume)
169
+ );
170
+ const dialogPresentation = normalizeTaskDialogPresentation(
171
+ dialogPresentationRaw
172
+ );
173
+
174
+ const handleDialogPresentationChange = (value: string) => {
175
+ const nextValue: TaskDialogPresentation = normalizeTaskDialogPresentation(
176
+ value,
177
+ 'compact'
178
+ );
179
+ updateDialogPresentation.mutate({
180
+ configId: TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
181
+ value: nextValue,
182
+ });
183
+ };
134
184
 
135
185
  return (
136
186
  <div className="space-y-8">
@@ -163,6 +213,82 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
163
213
  />
164
214
  </SettingItemTab>
165
215
  <Separator />
216
+ <SettingItemTab
217
+ title={t('sound_effects')}
218
+ description={t('sound_effects_description')}
219
+ >
220
+ <Switch
221
+ aria-label={t('sound_effects')}
222
+ checked={soundEffectsEnabled}
223
+ onCheckedChange={setSoundEffectsEnabled}
224
+ disabled={soundEffectsEnabledLoading || soundEffectsEnabledPending}
225
+ />
226
+ </SettingItemTab>
227
+ <Separator />
228
+ <SettingItemTab
229
+ title={t('sound_effects_volume')}
230
+ description={t('sound_effects_volume_description')}
231
+ >
232
+ <Select
233
+ value={normalizedSoundEffectsVolume}
234
+ onValueChange={handleSoundEffectsVolumeChange}
235
+ disabled={
236
+ soundEffectsVolumeLoading ||
237
+ updateSoundEffectsVolume.isPending ||
238
+ !soundEffectsEnabled
239
+ }
240
+ >
241
+ <SelectTrigger
242
+ aria-label={t('sound_effects_volume')}
243
+ className="w-36"
244
+ >
245
+ <SelectValue />
246
+ </SelectTrigger>
247
+ <SelectContent>
248
+ <SelectItem value="15">
249
+ {t('sound_effects_volume_soft')}
250
+ </SelectItem>
251
+ <SelectItem value="35">
252
+ {t('sound_effects_volume_balanced')}
253
+ </SelectItem>
254
+ <SelectItem value="60">
255
+ {t('sound_effects_volume_lively')}
256
+ </SelectItem>
257
+ <SelectItem value="85">
258
+ {t('sound_effects_volume_bold')}
259
+ </SelectItem>
260
+ </SelectContent>
261
+ </Select>
262
+ </SettingItemTab>
263
+ <Separator />
264
+ <SettingItemTab
265
+ title={t('dialog_presentation')}
266
+ description={t('dialog_presentation_description')}
267
+ >
268
+ <Select
269
+ value={dialogPresentation}
270
+ onValueChange={handleDialogPresentationChange}
271
+ disabled={
272
+ dialogPresentationLoading || updateDialogPresentation.isPending
273
+ }
274
+ >
275
+ <SelectTrigger
276
+ aria-label={t('dialog_presentation')}
277
+ className="w-36"
278
+ >
279
+ <SelectValue />
280
+ </SelectTrigger>
281
+ <SelectContent>
282
+ <SelectItem value="compact">
283
+ {t('dialog_presentation_compact')}
284
+ </SelectItem>
285
+ <SelectItem value="fullscreen">
286
+ {t('dialog_presentation_immersive')}
287
+ </SelectItem>
288
+ </SelectContent>
289
+ </Select>
290
+ </SettingItemTab>
291
+ <Separator />
166
292
  <SettingItemTab
167
293
  title={t('draft_mode')}
168
294
  description={t('draft_mode_description')}
@@ -0,0 +1,146 @@
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
+ dialogPresentation: 'compact',
22
+ soundEffectsEnabled: true,
23
+ soundEffectsVolume: '35',
24
+ },
25
+ }));
26
+
27
+ vi.mock('next-intl', () => ({
28
+ useTranslations: () => (key: string) => key,
29
+ }));
30
+
31
+ vi.mock('@tuturuuu/ui/hooks/use-user-config', () => ({
32
+ useUserBooleanConfig: (configId: string, defaultValue = false) => {
33
+ if (configId === 'TASK_SOUND_EFFECTS_ENABLED') {
34
+ return {
35
+ isLoading: false,
36
+ isPending: false,
37
+ setValue: mockSetSoundEffectsEnabled,
38
+ toggle: vi.fn(),
39
+ value: mockConfigState.soundEffectsEnabled,
40
+ };
41
+ }
42
+
43
+ return {
44
+ isLoading: false,
45
+ isPending: false,
46
+ setValue: vi.fn(),
47
+ toggle: vi.fn(),
48
+ value: defaultValue,
49
+ };
50
+ },
51
+ useUserConfig: (configId: string, defaultValue = '') => ({
52
+ data:
53
+ configId === 'TASK_SOUND_EFFECTS_VOLUME'
54
+ ? mockConfigState.soundEffectsVolume
55
+ : configId === 'TASK_DIALOG_DEFAULT_PRESENTATION'
56
+ ? mockConfigState.dialogPresentation
57
+ : defaultValue,
58
+ isLoading: false,
59
+ }),
60
+ useUpdateUserConfig: () => ({
61
+ isPending: false,
62
+ mutate: mockUpdateUserConfigMutate,
63
+ }),
64
+ }));
65
+
66
+ function renderWithQueryClient(children: ReactNode) {
67
+ const queryClient = new QueryClient({
68
+ defaultOptions: {
69
+ mutations: { retry: false },
70
+ queries: { retry: false },
71
+ },
72
+ });
73
+
74
+ return render(
75
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
76
+ );
77
+ }
78
+
79
+ describe('task sound settings controls', () => {
80
+ beforeEach(() => {
81
+ vi.clearAllMocks();
82
+ mockConfigState.dialogPresentation = 'compact';
83
+ mockConfigState.soundEffectsEnabled = true;
84
+ mockConfigState.soundEffectsVolume = '35';
85
+ vi.stubGlobal(
86
+ 'fetch',
87
+ vi.fn(() =>
88
+ Promise.resolve({
89
+ json: () =>
90
+ Promise.resolve({
91
+ fade_completed_tasks: false,
92
+ task_auto_assign_to_self: false,
93
+ }),
94
+ ok: true,
95
+ })
96
+ )
97
+ );
98
+ });
99
+
100
+ it('renders task settings sound controls and persists the switch value', async () => {
101
+ renderWithQueryClient(<TaskSettings />);
102
+
103
+ expect(await screen.findByText('sound_effects')).toBeInTheDocument();
104
+ expect(screen.getByText('sound_effects_volume')).toBeInTheDocument();
105
+
106
+ fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
107
+
108
+ expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
109
+ });
110
+
111
+ it('renders task dialog presentation setting and persists immersive mode', async () => {
112
+ renderWithQueryClient(<TaskSettings />);
113
+
114
+ expect(await screen.findByText('dialog_presentation')).toBeInTheDocument();
115
+
116
+ fireEvent.click(
117
+ screen.getByRole('combobox', { name: 'dialog_presentation' })
118
+ );
119
+ fireEvent.click(screen.getByText('dialog_presentation_immersive'));
120
+
121
+ expect(mockUpdateUserConfigMutate).toHaveBeenCalledWith({
122
+ configId: 'TASK_DIALOG_DEFAULT_PRESENTATION',
123
+ value: 'fullscreen',
124
+ });
125
+ });
126
+
127
+ it('renders the quick settings sound switch and persists changes', async () => {
128
+ renderWithQueryClient(<QuickSettingsPopover />);
129
+
130
+ const trigger = screen.getByRole('button', { name: 'Quick Settings' });
131
+
132
+ await waitFor(() => {
133
+ expect(trigger).not.toBeDisabled();
134
+ });
135
+
136
+ fireEvent.click(trigger);
137
+
138
+ await waitFor(() => {
139
+ expect(screen.getByText('sound_effects')).toBeInTheDocument();
140
+ });
141
+
142
+ fireEvent.click(screen.getByRole('switch', { name: 'sound_effects' }));
143
+
144
+ expect(mockSetSoundEffectsEnabled).toHaveBeenCalledWith(false);
145
+ });
146
+ });
@@ -15,6 +15,8 @@ import {
15
15
  } from 'react';
16
16
 
17
17
  export const SIDEBAR_BEHAVIOR_COOKIE_NAME = 'sidebar-behavior';
18
+ export const SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME =
19
+ 'sidebar-behavior-updated-at';
18
20
  export const SIDEBAR_BEHAVIOR_CONFIG_KEY = 'SIDEBAR_BEHAVIOR';
19
21
 
20
22
  export type SidebarBehavior = 'expanded' | 'collapsed' | 'hover';
@@ -33,7 +35,33 @@ export const SidebarContext = createContext<SidebarContextProps | undefined>(
33
35
  );
34
36
 
35
37
  // Persistent cookie options — ensures setting survives browser restarts
36
- const COOKIE_OPTIONS = { maxAge: 365 * 24 * 60 * 60, path: '/' } as const;
38
+ export const SIDEBAR_COOKIE_OPTIONS = {
39
+ maxAge: 365 * 24 * 60 * 60,
40
+ path: '/',
41
+ } as const;
42
+
43
+ function parseSidebarBehaviorUpdatedAt(value: string | undefined | null) {
44
+ if (!value) return null;
45
+
46
+ const updatedAt = Number(value);
47
+ return Number.isSafeInteger(updatedAt) && updatedAt > 0 ? updatedAt : null;
48
+ }
49
+
50
+ function getSidebarBehaviorUpdatedAtFromDocument() {
51
+ if (typeof document === 'undefined') return null;
52
+
53
+ const cookie = document.cookie
54
+ .split('; ')
55
+ .find((part) =>
56
+ part.startsWith(`${SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME}=`)
57
+ );
58
+
59
+ if (!cookie) return null;
60
+
61
+ return parseSidebarBehaviorUpdatedAt(
62
+ decodeURIComponent(cookie.split('=').slice(1).join('='))
63
+ );
64
+ }
37
65
 
38
66
  type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
39
67
  behavior: SidebarBehavior;
@@ -41,6 +69,7 @@ type SidebarRemoteBehaviorBridgeComponent = ComponentType<{
41
69
  localOverrideVersion: number;
42
70
  onApplyRemoteBehavior: (newBehavior: SidebarBehavior) => void;
43
71
  onRemoteBehaviorAvailable: (remoteBehavior: SidebarBehavior) => void;
72
+ behaviorUpdatedAt: number | null;
44
73
  userChangeVersion: number;
45
74
  }>;
46
75
 
@@ -70,11 +99,16 @@ function useSidebarRemoteBehaviorBridge() {
70
99
  export const SidebarProvider = ({
71
100
  children,
72
101
  initialBehavior,
102
+ initialBehaviorUpdatedAt,
73
103
  }: {
74
104
  children: ReactNode;
75
105
  initialBehavior: SidebarBehavior;
106
+ initialBehaviorUpdatedAt?: number | null;
76
107
  }) => {
77
108
  const [behavior, setBehavior] = useState<SidebarBehavior>(initialBehavior);
109
+ const [behaviorUpdatedAt, setBehaviorUpdatedAt] = useState<number | null>(
110
+ () => initialBehaviorUpdatedAt ?? getSidebarBehaviorUpdatedAtFromDocument()
111
+ );
78
112
  const [localOverride, setLocalOverrideRaw] = useLocalStorage(
79
113
  'sidebar-local-override',
80
114
  false
@@ -86,14 +120,41 @@ export const SidebarProvider = ({
86
120
  const [localOverrideVersion, setLocalOverrideVersion] = useState(0);
87
121
  const RemoteBehaviorBridge = useSidebarRemoteBehaviorBridge();
88
122
 
89
- const applyBehavior = useCallback((newBehavior: SidebarBehavior) => {
90
- setBehavior(newBehavior);
91
- setCookie(SIDEBAR_BEHAVIOR_COOKIE_NAME, newBehavior, COOKIE_OPTIONS);
92
- }, []);
123
+ useEffect(() => {
124
+ if (initialBehaviorUpdatedAt !== undefined) return;
125
+
126
+ const updatedAt = getSidebarBehaviorUpdatedAtFromDocument();
127
+ if (updatedAt !== null) setBehaviorUpdatedAt(updatedAt);
128
+ }, [initialBehaviorUpdatedAt]);
129
+
130
+ const applyBehavior = useCallback(
131
+ (
132
+ newBehavior: SidebarBehavior,
133
+ options: { markLocalChange?: boolean } = {}
134
+ ) => {
135
+ setBehavior(newBehavior);
136
+ setCookie(
137
+ SIDEBAR_BEHAVIOR_COOKIE_NAME,
138
+ newBehavior,
139
+ SIDEBAR_COOKIE_OPTIONS
140
+ );
141
+
142
+ if (!options.markLocalChange) return;
143
+
144
+ const updatedAt = Date.now();
145
+ setBehaviorUpdatedAt(updatedAt);
146
+ setCookie(
147
+ SIDEBAR_BEHAVIOR_UPDATED_AT_COOKIE_NAME,
148
+ String(updatedAt),
149
+ SIDEBAR_COOKIE_OPTIONS
150
+ );
151
+ },
152
+ []
153
+ );
93
154
 
94
155
  const handleBehaviorChange = useCallback(
95
156
  (newBehavior: SidebarBehavior) => {
96
- applyBehavior(newBehavior);
157
+ applyBehavior(newBehavior, { markLocalChange: true });
97
158
 
98
159
  if (!localOverride) {
99
160
  setUserChangeVersion((version) => version + 1);
@@ -127,6 +188,7 @@ export const SidebarProvider = ({
127
188
  {RemoteBehaviorBridge && (
128
189
  <RemoteBehaviorBridge
129
190
  behavior={behavior}
191
+ behaviorUpdatedAt={behaviorUpdatedAt}
130
192
  localOverride={localOverride}
131
193
  localOverrideVersion={localOverrideVersion}
132
194
  onApplyRemoteBehavior={applyBehavior}