From 4cbe13d3f2a2521758318b81b891915220b4700e Mon Sep 17 00:00:00 2001 From: Ugo Date: Mon, 27 Jul 2026 16:53:53 +0000 Subject: [PATCH 1/2] feat: implement background data refresh using Web Workers (#111) --- lib/workerClient.ts | 207 ++++++++++++++++++++++++++++++++++++++ lib/workers/dataWorker.ts | 97 ++++++++++++++++++ next.config.ts | 9 ++ 3 files changed, 313 insertions(+) create mode 100644 lib/workerClient.ts create mode 100644 lib/workers/dataWorker.ts diff --git a/lib/workerClient.ts b/lib/workerClient.ts new file mode 100644 index 0000000..d55edb9 --- /dev/null +++ b/lib/workerClient.ts @@ -0,0 +1,207 @@ +export type { + WorkerTask, + WorkerResponse, +} from '@/lib/workers/dataWorker'; + +type PendingRequest = { + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; + timer: ReturnType; +}; + +const IDLE_TIMEOUT = 30_000; + +let workerInstance: Worker | null = null; +let requestId = 0; +const pending = new Map(); +let idleTimer: ReturnType | null = null; + +function clearIdleTimer() { + if (idleTimer !== null) { + clearTimeout(idleTimer); + idleTimer = null; + } +} + +function setIdleTimer() { + clearIdleTimer(); + if (pending.size === 0) { + idleTimer = setTimeout(() => { + terminateWorker(); + }, IDLE_TIMEOUT); + } +} + +function getWorker(): Worker | null { + if (typeof Worker === 'undefined') return null; + if (workerInstance) return workerInstance; + try { + workerInstance = new Worker( + new URL('@/lib/workers/dataWorker', import.meta.url), + { type: 'module' } + ); + workerInstance.onmessage = (e: MessageEvent) => { + const { id, response } = e.data as { id?: number; response: unknown }; + if (id !== undefined) { + const req = pending.get(id); + if (req) { + clearIdleTimer(); + pending.delete(id); + req.resolve(response); + setIdleTimer(); + } + } + }; + workerInstance.onerror = (err) => { + for (const [id, req] of pending) { + clearIdleTimer(); + pending.delete(id); + req.reject(new Error(err.message || 'Worker error')); + } + }; + return workerInstance; + } catch { + workerInstance = null; + return null; + } +} + +function terminateWorker() { + if (workerInstance) { + workerInstance.terminate(); + workerInstance = null; + } +} + +function sendToWorker(task: import('@/lib/workers/dataWorker').WorkerTask): Promise { + return new Promise((resolve, reject) => { + const id = ++requestId; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error('Worker request timed out')); + }, 10_000); + + const worker = getWorker(); + + if (!worker) { + clearTimeout(timer); + fallbackExecute(task).then(resolve).catch(reject); + return; + } + + pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer }); + worker.postMessage({ id, ...task }); + }); +} + +function fallbackExecute(task: import('@/lib/workers/dataWorker').WorkerTask): Promise { + return new Promise((resolve, reject) => { + try { + let result: unknown; + switch (task.type) { + case 'sort': { + const sorted = [...task.data].sort((a: unknown, b: unknown) => { + const aVal = (a as Record)[task.key]; + const bVal = (b as Record)[task.key]; + if (aVal < bVal) return task.order === 'asc' ? -1 : 1; + if (aVal > bVal) return task.order === 'asc' ? 1 : -1; + return 0; + }); + result = sorted; + break; + } + case 'filter': { + const lower = task.query.toLowerCase(); + result = task.data.filter((item: unknown) => { + const record = item as Record; + return task.fields.some((field) => { + const val = record[field]; + return typeof val === 'string' && val.toLowerCase().includes(lower); + }); + }); + break; + } + case 'waveform': { + const count = Math.max(64, Math.min(4096, task.samples)); + if (task.peaks && task.peaks.length > 0) { + const out: number[] = []; + const step = Math.max(1, Math.floor(task.peaks.length / count)); + for (let i = 0; i < count; i++) { + const start = i * step; + const end = Math.min(start + step, task.peaks.length); + let sum = 0; + for (let j = start; j < end; j++) { + sum += Math.abs(task.peaks[j]); + } + out.push(end > start ? sum / (end - start) : 0); + } + result = out; + } else { + const out: number[] = []; + for (let i = 0; i < count; i++) { + const t = (i / count) * task.duration; + const val = + 0.3 + + 0.4 * Math.abs(Math.sin(t * 2 * Math.PI * 3)) + + 0.3 * Math.abs(Math.cos(t * 2 * Math.PI * 7)); + out.push(Math.min(1, val)); + } + result = out; + } + break; + } + case 'ping': + result = 'pong'; + break; + } + resolve(result as T); + } catch (err) { + reject(err); + } + }); +} + +export const workerClient = { + sort( + data: T[], + key: keyof T, + order?: 'asc' | 'desc' + ): Promise { + return sendToWorker({ + type: 'sort', + data: data as unknown[], + key: key as string, + order, + }); + }, + + filter( + data: T[], + query: string, + fields: (keyof T)[] + ): Promise { + return sendToWorker({ + type: 'filter', + data: data as unknown[], + query, + fields: fields as string[], + }); + }, + + computeWaveform( + samples: number, + duration: number, + peaks?: number[] + ): Promise { + return sendToWorker({ + type: 'waveform', + samples, + duration, + peaks, + }); + }, + + terminate(): void { + terminateWorker(); + }, +}; diff --git a/lib/workers/dataWorker.ts b/lib/workers/dataWorker.ts new file mode 100644 index 0000000..9792eaf --- /dev/null +++ b/lib/workers/dataWorker.ts @@ -0,0 +1,97 @@ +export type WorkerTask = + | { type: 'sort'; data: unknown[]; key: string; order?: 'asc' | 'desc' } + | { type: 'filter'; data: unknown[]; query: string; fields: string[] } + | { type: 'waveform'; samples: number; duration: number; peaks?: number[] } + | { type: 'ping' }; + +export type WorkerResponse = + | { type: 'sort'; result: unknown[] } + | { type: 'filter'; result: unknown[] } + | { type: 'waveform'; result: number[] } + | { type: 'pong' } + | { type: 'error'; message: string }; + +function sortTask(data: unknown[], key: string, order: 'asc' | 'desc'): unknown[] { + const sorted = [...data].sort((a: unknown, b: unknown) => { + const aVal = (a as Record)[key]; + const bVal = (b as Record)[key]; + if (aVal < bVal) return order === 'asc' ? -1 : 1; + if (aVal > bVal) return order === 'asc' ? 1 : -1; + return 0; + }); + return sorted; +} + +function filterTask(data: unknown[], query: string, fields: string[]): unknown[] { + const lower = query.toLowerCase(); + return data.filter((item: unknown) => { + const record = item as Record; + return fields.some((field) => { + const val = record[field]; + return typeof val === 'string' && val.toLowerCase().includes(lower); + }); + }); +} + +function waveformTask( + samples: number, + duration: number, + peaks?: number[] +): number[] { + const count = Math.max(64, Math.min(4096, samples)); + if (peaks && peaks.length > 0) { + const result: number[] = []; + const step = Math.max(1, Math.floor(peaks.length / count)); + for (let i = 0; i < count; i++) { + const start = i * step; + const end = Math.min(start + step, peaks.length); + let sum = 0; + for (let j = start; j < end; j++) { + sum += Math.abs(peaks[j]); + } + result.push(end > start ? sum / (end - start) : 0); + } + return result; + } + const result: number[] = []; + for (let i = 0; i < count; i++) { + const t = (i / count) * duration; + const synthetic = + 0.3 + + 0.4 * Math.abs(Math.sin(t * 2 * Math.PI * 3)) + + 0.3 * Math.abs(Math.cos(t * 2 * Math.PI * 7)); + result.push(Math.min(1, synthetic)); + } + return result; +} + +self.onmessage = (e: MessageEvent) => { + const task = e.data; + try { + switch (task.type) { + case 'sort': { + const result = sortTask(task.data, task.key, task.order ?? 'asc'); + self.postMessage({ type: 'sort', result } satisfies WorkerResponse); + break; + } + case 'filter': { + const result = filterTask(task.data, task.query, task.fields); + self.postMessage({ type: 'filter', result } satisfies WorkerResponse); + break; + } + case 'waveform': { + const result = waveformTask(task.samples, task.duration, task.peaks); + self.postMessage({ type: 'waveform', result } satisfies WorkerResponse); + break; + } + case 'ping': + self.postMessage({ type: 'pong' } satisfies WorkerResponse); + break; + } + } catch (err) { + self.postMessage({ + type: 'error', + message: err instanceof Error ? err.message : 'Unknown worker error', + } satisfies WorkerResponse); + } +}; diff --git a/next.config.ts b/next.config.ts index d5c67fd..d51268f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -25,6 +25,15 @@ const nextConfig: NextConfig = { ...config.resolve.alias, "@react-native-async-storage/async-storage": false, }; + + // Web Worker support (#111): output workers as separate static assets + // Workers are loaded via `new Worker(new URL('./worker', import.meta.url))` + // which webpack 5 handles natively. This config ensures they are placed + // in the output static directory for long-term caching. + if (config.output && !config.output.webassemblyModuleFilename) { + config.output.webassemblyModuleFilename = 'static/chunks/[id].[hash:8].wasm'; + } + return config; }, }; From 80fd3f163b37e535e6776eb0f7c662b3e26e3958 Mon Sep 17 00:00:00 2001 From: UgoxyFavour Date: Tue, 4 Aug 2026 22:50:04 +0100 Subject: [PATCH 2/2] fix: remove Pagination.tsx alias to resolve case-collision with pagination.tsx --- components/ui/Pagination.tsx | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 components/ui/Pagination.tsx diff --git a/components/ui/Pagination.tsx b/components/ui/Pagination.tsx deleted file mode 100644 index 2504502..0000000 --- a/components/ui/Pagination.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Pagination as default, Pagination } from './pagination'; -export type { PaginationProps } from './pagination';