@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
@@ -0,0 +1,189 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import {
7
+ __resetTaskSoundEffectsForTests,
8
+ configureTaskSoundEffects,
9
+ dispatchTaskSoundCue,
10
+ playTaskSoundCue,
11
+ } from './task-sound-effects';
12
+
13
+ class MockAudioParam {
14
+ value = 0;
15
+ setValueAtTime = vi.fn();
16
+ linearRampToValueAtTime = vi.fn();
17
+ exponentialRampToValueAtTime = vi.fn();
18
+ }
19
+
20
+ class MockOscillatorNode {
21
+ type: OscillatorType = 'sine';
22
+ frequency = new MockAudioParam();
23
+ connect = vi.fn();
24
+ start = vi.fn();
25
+ stop = vi.fn();
26
+ }
27
+
28
+ class MockGainNode {
29
+ gain = new MockAudioParam();
30
+ connect = vi.fn();
31
+ }
32
+
33
+ class MockAudioBuffer {
34
+ private readonly data: Float32Array;
35
+
36
+ constructor(frameCount: number) {
37
+ this.data = new Float32Array(frameCount);
38
+ }
39
+
40
+ getChannelData = vi.fn(() => this.data);
41
+ }
42
+
43
+ class MockAudioBufferSourceNode {
44
+ buffer: AudioBuffer | null = null;
45
+ connect = vi.fn();
46
+ start = vi.fn();
47
+ stop = vi.fn();
48
+ }
49
+
50
+ class MockAudioContext {
51
+ static instances: MockAudioContext[] = [];
52
+
53
+ currentTime = 1;
54
+ destination = {} as AudioDestinationNode;
55
+ sampleRate = 44_100;
56
+ state: AudioContextState = 'running';
57
+ oscillators: MockOscillatorNode[] = [];
58
+ gains: MockGainNode[] = [];
59
+ bufferSources: MockAudioBufferSourceNode[] = [];
60
+ buffers: MockAudioBuffer[] = [];
61
+ resume = vi.fn(() => Promise.resolve());
62
+
63
+ constructor() {
64
+ MockAudioContext.instances.push(this);
65
+ }
66
+
67
+ createOscillator() {
68
+ const oscillator = new MockOscillatorNode();
69
+ this.oscillators.push(oscillator);
70
+ return oscillator as unknown as OscillatorNode;
71
+ }
72
+
73
+ createGain() {
74
+ const gain = new MockGainNode();
75
+ this.gains.push(gain);
76
+ return gain as unknown as GainNode;
77
+ }
78
+
79
+ createBuffer(_channels: number, frameCount: number) {
80
+ const buffer = new MockAudioBuffer(frameCount);
81
+ this.buffers.push(buffer);
82
+ return buffer as unknown as AudioBuffer;
83
+ }
84
+
85
+ createBufferSource() {
86
+ const source = new MockAudioBufferSourceNode();
87
+ this.bufferSources.push(source);
88
+ return source as unknown as AudioBufferSourceNode;
89
+ }
90
+ }
91
+
92
+ function installMockAudioContext() {
93
+ Object.defineProperty(window, 'AudioContext', {
94
+ configurable: true,
95
+ value: MockAudioContext,
96
+ });
97
+ Object.defineProperty(window, 'webkitAudioContext', {
98
+ configurable: true,
99
+ value: undefined,
100
+ });
101
+ }
102
+
103
+ describe('task sound effects', () => {
104
+ beforeEach(() => {
105
+ MockAudioContext.instances = [];
106
+ __resetTaskSoundEffectsForTests();
107
+ installMockAudioContext();
108
+ });
109
+
110
+ afterEach(() => {
111
+ vi.unstubAllGlobals();
112
+ __resetTaskSoundEffectsForTests();
113
+ });
114
+
115
+ it('no-ops without window', () => {
116
+ vi.stubGlobal('window', undefined);
117
+
118
+ expect(() => dispatchTaskSoundCue('create')).not.toThrow();
119
+ expect(() => playTaskSoundCue('create')).not.toThrow();
120
+ expect(MockAudioContext.instances).toHaveLength(0);
121
+ });
122
+
123
+ it('no-ops when Web Audio is unavailable', () => {
124
+ Object.defineProperty(window, 'AudioContext', {
125
+ configurable: true,
126
+ value: undefined,
127
+ });
128
+ Object.defineProperty(window, 'webkitAudioContext', {
129
+ configurable: true,
130
+ value: undefined,
131
+ });
132
+
133
+ expect(() => playTaskSoundCue('create')).not.toThrow();
134
+ expect(MockAudioContext.instances).toHaveLength(0);
135
+ });
136
+
137
+ it('creates the audio context lazily on the first cue', () => {
138
+ configureTaskSoundEffects({ enabled: true, volume: 35 });
139
+
140
+ expect(MockAudioContext.instances).toHaveLength(0);
141
+
142
+ playTaskSoundCue('update');
143
+
144
+ expect(MockAudioContext.instances).toHaveLength(1);
145
+ expect(MockAudioContext.instances[0]?.oscillators.length).toBeGreaterThan(
146
+ 0
147
+ );
148
+ expect(MockAudioContext.instances[0]?.gains.length).toBeGreaterThan(0);
149
+ });
150
+
151
+ it('respects disabled sound effects and zero volume', () => {
152
+ configureTaskSoundEffects({ enabled: false, volume: 35 });
153
+ playTaskSoundCue('complete');
154
+ expect(MockAudioContext.instances).toHaveLength(0);
155
+
156
+ configureTaskSoundEffects({ enabled: true, volume: 0 });
157
+ playTaskSoundCue('complete');
158
+ expect(MockAudioContext.instances).toHaveLength(0);
159
+ });
160
+
161
+ it('clamps volume and intensity while reusing one context', () => {
162
+ configureTaskSoundEffects({ enabled: true, volume: 500 });
163
+
164
+ playTaskSoundCue({ cue: 'complete', count: 999, intensity: 99 });
165
+ playTaskSoundCue({ cue: 'move', count: 1, intensity: 1 });
166
+
167
+ expect(MockAudioContext.instances).toHaveLength(1);
168
+ const context = MockAudioContext.instances[0];
169
+ const firstMasterGain = context?.gains[0];
170
+
171
+ expect(firstMasterGain?.gain.linearRampToValueAtTime).toHaveBeenCalledWith(
172
+ 1,
173
+ expect.any(Number)
174
+ );
175
+ expect(context?.oscillators.length).toBeGreaterThanOrEqual(6);
176
+ });
177
+
178
+ it('uses one richer aggregate playback path for bulk complete cues', () => {
179
+ configureTaskSoundEffects({ enabled: true, volume: 35 });
180
+
181
+ playTaskSoundCue({ cue: 'complete', count: 8 });
182
+
183
+ expect(MockAudioContext.instances).toHaveLength(1);
184
+ const context = MockAudioContext.instances[0];
185
+
186
+ expect(context?.oscillators).toHaveLength(5);
187
+ expect(context?.gains).toHaveLength(6);
188
+ });
189
+ });
@@ -0,0 +1,468 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useUserBooleanConfig,
5
+ useUserConfig,
6
+ } from '@tuturuuu/ui/hooks/use-user-config';
7
+ import { useEffect } from 'react';
8
+
9
+ export const TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID =
10
+ 'TASK_SOUND_EFFECTS_ENABLED';
11
+ export const TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID = 'TASK_SOUND_EFFECTS_VOLUME';
12
+ export const DEFAULT_TASK_SOUND_EFFECTS_VOLUME = 35;
13
+
14
+ const TASK_SOUND_EFFECT_EVENT = 'tuturuuu:task-sound-effect';
15
+ const VALID_TASK_SOUND_CUES = new Set([
16
+ 'create',
17
+ 'complete',
18
+ 'move',
19
+ 'update',
20
+ 'delete',
21
+ ] as const);
22
+
23
+ export type TaskSoundCue = 'create' | 'complete' | 'move' | 'update' | 'delete';
24
+
25
+ export interface TaskSoundCueOptions {
26
+ cue: TaskSoundCue;
27
+ count?: number;
28
+ intensity?: number;
29
+ }
30
+
31
+ interface TaskSoundPreferences {
32
+ enabled: boolean;
33
+ volume: number;
34
+ }
35
+
36
+ interface NormalizedTaskSoundCueOptions {
37
+ cue: TaskSoundCue;
38
+ count: number;
39
+ intensity: number;
40
+ }
41
+
42
+ type AudioContextConstructor = new () => AudioContext;
43
+
44
+ let audioContext: AudioContext | null = null;
45
+ let preferences: TaskSoundPreferences = {
46
+ enabled: true,
47
+ volume: DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
48
+ };
49
+
50
+ function clamp(value: number, min: number, max: number) {
51
+ return Math.min(max, Math.max(min, value));
52
+ }
53
+
54
+ export function clampTaskSoundEffectsVolume(
55
+ value: number | string | null | undefined
56
+ ) {
57
+ const parsed = typeof value === 'number' ? value : Number(value);
58
+ if (!Number.isFinite(parsed)) return DEFAULT_TASK_SOUND_EFFECTS_VOLUME;
59
+ return Math.round(clamp(parsed, 0, 100));
60
+ }
61
+
62
+ function normalizeTaskSoundCue(
63
+ input: TaskSoundCue | TaskSoundCueOptions | null | undefined
64
+ ): NormalizedTaskSoundCueOptions | null {
65
+ const cue = typeof input === 'string' ? input : input?.cue;
66
+
67
+ if (!cue || !VALID_TASK_SOUND_CUES.has(cue)) {
68
+ return null;
69
+ }
70
+
71
+ const cueOptions =
72
+ input && typeof input === 'object' ? input : ({} as TaskSoundCueOptions);
73
+ const rawCount = cueOptions.count;
74
+ const rawIntensity = cueOptions.intensity;
75
+
76
+ return {
77
+ cue,
78
+ count: Math.round(clamp(Number(rawCount) || 1, 1, 24)),
79
+ intensity: clamp(Number(rawIntensity) || 1, 0.35, 2),
80
+ };
81
+ }
82
+
83
+ export function configureTaskSoundEffects({
84
+ enabled,
85
+ volume,
86
+ }: {
87
+ enabled?: boolean;
88
+ volume?: number | string | null;
89
+ }) {
90
+ preferences = {
91
+ enabled: enabled ?? preferences.enabled,
92
+ volume:
93
+ volume === undefined
94
+ ? preferences.volume
95
+ : clampTaskSoundEffectsVolume(volume),
96
+ };
97
+ }
98
+
99
+ function getAudioContextConstructor(): AudioContextConstructor | null {
100
+ if (typeof window === 'undefined') {
101
+ return null;
102
+ }
103
+
104
+ const browserWindow = window as typeof window & {
105
+ webkitAudioContext?: AudioContextConstructor;
106
+ };
107
+
108
+ return window.AudioContext ?? browserWindow.webkitAudioContext ?? null;
109
+ }
110
+
111
+ function getAudioContext() {
112
+ const AudioContextCtor = getAudioContextConstructor();
113
+ if (!AudioContextCtor) return null;
114
+
115
+ if (audioContext && audioContext.state !== 'closed') {
116
+ return audioContext;
117
+ }
118
+
119
+ try {
120
+ audioContext = new AudioContextCtor();
121
+ return audioContext;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ function resumeAudioContext(context: AudioContext) {
128
+ if (context.state !== 'suspended') return;
129
+ void context.resume().catch(() => undefined);
130
+ }
131
+
132
+ function scheduleGainEnvelope(
133
+ gain: AudioParam,
134
+ startAt: number,
135
+ duration: number,
136
+ peakGain: number,
137
+ attack = 0.01
138
+ ) {
139
+ const safeGain = Math.max(0.0001, peakGain);
140
+
141
+ gain.setValueAtTime(0.0001, startAt);
142
+ gain.exponentialRampToValueAtTime(safeGain, startAt + attack);
143
+ gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
144
+ }
145
+
146
+ function scheduleOscillator({
147
+ context,
148
+ destination,
149
+ type,
150
+ frequency,
151
+ endFrequency,
152
+ startAt,
153
+ duration,
154
+ gain,
155
+ }: {
156
+ context: AudioContext;
157
+ destination: AudioNode;
158
+ type: OscillatorType;
159
+ frequency: number;
160
+ endFrequency?: number;
161
+ startAt: number;
162
+ duration: number;
163
+ gain: number;
164
+ }) {
165
+ const oscillator = context.createOscillator();
166
+ const amp = context.createGain();
167
+ const finalFrequency = endFrequency ?? frequency;
168
+
169
+ oscillator.type = type;
170
+ oscillator.frequency.setValueAtTime(Math.max(20, frequency), startAt);
171
+
172
+ if (finalFrequency !== frequency) {
173
+ oscillator.frequency.exponentialRampToValueAtTime(
174
+ Math.max(20, finalFrequency),
175
+ startAt + duration
176
+ );
177
+ }
178
+
179
+ scheduleGainEnvelope(amp.gain, startAt, duration, gain);
180
+ oscillator.connect(amp);
181
+ amp.connect(destination);
182
+ oscillator.start(startAt);
183
+ oscillator.stop(startAt + duration + 0.02);
184
+ }
185
+
186
+ function scheduleNoiseBurst({
187
+ context,
188
+ destination,
189
+ startAt,
190
+ duration,
191
+ gain,
192
+ }: {
193
+ context: AudioContext;
194
+ destination: AudioNode;
195
+ startAt: number;
196
+ duration: number;
197
+ gain: number;
198
+ }) {
199
+ const sampleRate = context.sampleRate || 44_100;
200
+ const frameCount = Math.max(1, Math.floor(sampleRate * duration));
201
+ const buffer = context.createBuffer(1, frameCount, sampleRate);
202
+ const data = buffer.getChannelData(0);
203
+
204
+ for (let i = 0; i < frameCount; i += 1) {
205
+ const progress = i / frameCount;
206
+ data[i] = (Math.random() * 2 - 1) * (1 - progress) ** 2;
207
+ }
208
+
209
+ const source = context.createBufferSource();
210
+ const amp = context.createGain();
211
+
212
+ source.buffer = buffer;
213
+ scheduleGainEnvelope(amp.gain, startAt, duration, gain, 0.004);
214
+ source.connect(amp);
215
+ amp.connect(destination);
216
+ source.start(startAt);
217
+ source.stop(startAt + duration + 0.01);
218
+ }
219
+
220
+ function scheduleCreateCue(
221
+ context: AudioContext,
222
+ destination: AudioNode,
223
+ startAt: number,
224
+ scale: number
225
+ ) {
226
+ scheduleNoiseBurst({
227
+ context,
228
+ destination,
229
+ startAt,
230
+ duration: 0.022,
231
+ gain: 0.04 * scale,
232
+ });
233
+ scheduleOscillator({
234
+ context,
235
+ destination,
236
+ type: 'triangle',
237
+ frequency: 560,
238
+ endFrequency: 980,
239
+ startAt,
240
+ duration: 0.1,
241
+ gain: 0.11 * scale,
242
+ });
243
+ scheduleOscillator({
244
+ context,
245
+ destination,
246
+ type: 'sine',
247
+ frequency: 1180,
248
+ endFrequency: 1560,
249
+ startAt: startAt + 0.045,
250
+ duration: 0.15,
251
+ gain: 0.045 * scale,
252
+ });
253
+ }
254
+
255
+ function scheduleCompleteCue(
256
+ context: AudioContext,
257
+ destination: AudioNode,
258
+ startAt: number,
259
+ scale: number,
260
+ count: number
261
+ ) {
262
+ const isBulk = count > 1;
263
+ const baseDuration = isBulk ? 0.42 : 0.32;
264
+
265
+ for (const [index, frequency] of [392, 523.25, 659.25].entries()) {
266
+ scheduleOscillator({
267
+ context,
268
+ destination,
269
+ type: index === 0 ? 'triangle' : 'sine',
270
+ frequency,
271
+ endFrequency: frequency * 1.035,
272
+ startAt: startAt + index * 0.018,
273
+ duration: baseDuration,
274
+ gain: (index === 0 ? 0.065 : 0.045) * scale,
275
+ });
276
+ }
277
+
278
+ if (isBulk) {
279
+ for (const [index, frequency] of [783.99, 1046.5].entries()) {
280
+ scheduleOscillator({
281
+ context,
282
+ destination,
283
+ type: 'sine',
284
+ frequency,
285
+ endFrequency: frequency * 1.025,
286
+ startAt: startAt + 0.11 + index * 0.018,
287
+ duration: 0.32,
288
+ gain: 0.032 * scale,
289
+ });
290
+ }
291
+ }
292
+ }
293
+
294
+ function scheduleMoveCue(
295
+ context: AudioContext,
296
+ destination: AudioNode,
297
+ startAt: number,
298
+ scale: number
299
+ ) {
300
+ scheduleOscillator({
301
+ context,
302
+ destination,
303
+ type: 'triangle',
304
+ frequency: 420,
305
+ endFrequency: 610,
306
+ startAt,
307
+ duration: 0.08,
308
+ gain: 0.075 * scale,
309
+ });
310
+ scheduleNoiseBurst({
311
+ context,
312
+ destination,
313
+ startAt: startAt + 0.055,
314
+ duration: 0.018,
315
+ gain: 0.025 * scale,
316
+ });
317
+ }
318
+
319
+ function scheduleUpdateCue(
320
+ context: AudioContext,
321
+ destination: AudioNode,
322
+ startAt: number,
323
+ scale: number
324
+ ) {
325
+ scheduleOscillator({
326
+ context,
327
+ destination,
328
+ type: 'sine',
329
+ frequency: 650,
330
+ endFrequency: 760,
331
+ startAt,
332
+ duration: 0.085,
333
+ gain: 0.065 * scale,
334
+ });
335
+ scheduleOscillator({
336
+ context,
337
+ destination,
338
+ type: 'triangle',
339
+ frequency: 980,
340
+ startAt: startAt + 0.035,
341
+ duration: 0.07,
342
+ gain: 0.022 * scale,
343
+ });
344
+ }
345
+
346
+ function scheduleDeleteCue(
347
+ context: AudioContext,
348
+ destination: AudioNode,
349
+ startAt: number,
350
+ scale: number
351
+ ) {
352
+ scheduleOscillator({
353
+ context,
354
+ destination,
355
+ type: 'triangle',
356
+ frequency: 250,
357
+ endFrequency: 170,
358
+ startAt,
359
+ duration: 0.16,
360
+ gain: 0.075 * scale,
361
+ });
362
+ scheduleNoiseBurst({
363
+ context,
364
+ destination,
365
+ startAt: startAt + 0.015,
366
+ duration: 0.035,
367
+ gain: 0.018 * scale,
368
+ });
369
+ }
370
+
371
+ export function playTaskSoundCue(
372
+ input: TaskSoundCue | TaskSoundCueOptions | null | undefined
373
+ ) {
374
+ const cue = normalizeTaskSoundCue(input);
375
+ const volume = clampTaskSoundEffectsVolume(preferences.volume);
376
+
377
+ if (!cue || !preferences.enabled || volume <= 0) {
378
+ return;
379
+ }
380
+
381
+ const context = getAudioContext();
382
+ if (!context) return;
383
+
384
+ resumeAudioContext(context);
385
+
386
+ const startAt = context.currentTime + 0.006;
387
+ const masterGain = context.createGain();
388
+ const bulkScale = cue.count > 1 ? Math.min(1.55, 1 + cue.count * 0.06) : 1;
389
+ const scale = clamp((volume / 100) * cue.intensity * bulkScale, 0.05, 1);
390
+
391
+ masterGain.gain.setValueAtTime(0.0001, startAt);
392
+ masterGain.gain.linearRampToValueAtTime(scale, startAt + 0.01);
393
+ masterGain.gain.exponentialRampToValueAtTime(0.0001, startAt + 0.65);
394
+ masterGain.connect(context.destination);
395
+
396
+ switch (cue.cue) {
397
+ case 'create':
398
+ scheduleCreateCue(context, masterGain, startAt, scale);
399
+ break;
400
+ case 'complete':
401
+ scheduleCompleteCue(context, masterGain, startAt, scale, cue.count);
402
+ break;
403
+ case 'move':
404
+ scheduleMoveCue(context, masterGain, startAt, scale);
405
+ break;
406
+ case 'update':
407
+ scheduleUpdateCue(context, masterGain, startAt, scale);
408
+ break;
409
+ case 'delete':
410
+ scheduleDeleteCue(context, masterGain, startAt, scale);
411
+ break;
412
+ }
413
+ }
414
+
415
+ export function dispatchTaskSoundCue(
416
+ input: TaskSoundCue | TaskSoundCueOptions
417
+ ) {
418
+ if (typeof window === 'undefined') return;
419
+
420
+ const cue = normalizeTaskSoundCue(input);
421
+ if (!cue) return;
422
+
423
+ window.dispatchEvent(
424
+ new CustomEvent<TaskSoundCueOptions>(TASK_SOUND_EFFECT_EVENT, {
425
+ detail: cue,
426
+ })
427
+ );
428
+ }
429
+
430
+ export function TaskSoundEffectsInitializer() {
431
+ const { value: enabled } = useUserBooleanConfig(
432
+ TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID,
433
+ true
434
+ );
435
+ const { data: volume } = useUserConfig(
436
+ TASK_SOUND_EFFECTS_VOLUME_CONFIG_ID,
437
+ String(DEFAULT_TASK_SOUND_EFFECTS_VOLUME)
438
+ );
439
+
440
+ useEffect(() => {
441
+ configureTaskSoundEffects({ enabled, volume });
442
+ }, [enabled, volume]);
443
+
444
+ useEffect(() => {
445
+ if (typeof window === 'undefined') return;
446
+
447
+ const handleSoundCue = (event: Event) => {
448
+ playTaskSoundCue(
449
+ (event as CustomEvent<TaskSoundCueOptions>).detail ?? null
450
+ );
451
+ };
452
+
453
+ window.addEventListener(TASK_SOUND_EFFECT_EVENT, handleSoundCue);
454
+ return () => {
455
+ window.removeEventListener(TASK_SOUND_EFFECT_EVENT, handleSoundCue);
456
+ };
457
+ }, []);
458
+
459
+ return null;
460
+ }
461
+
462
+ export function __resetTaskSoundEffectsForTests() {
463
+ audioContext = null;
464
+ preferences = {
465
+ enabled: true,
466
+ volume: DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
467
+ };
468
+ }