From 59562392c48341eed392875dab17cef4b7626a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sat, 19 Sep 2026 22:22:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=94=A8=20Debounce=20incoming=20collec?= =?UTF-8?q?tion=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 59 +++++++++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 53131c7..919540a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -229,7 +229,7 @@ import ResetPassword from '@/partials/ResetPassword.vue'; import SecondaryButton from '@/elements/SecondaryButton.vue'; import SetlistSet from '@/modals/SetlistSet.vue'; import SongSet from '@/modals/SongSet.vue'; -import { amberClient, type AmberClient, type UserInTenant, type AmberCollections, type AmberCollection, type UserInfo } from 'amber-client'; +import { amberClient, type AmberClient, type UserInTenant, type AmberCollections, type AmberCollection, type UserInfo, type CollectionDocument } from 'amber-client'; import { SongTag, can, UserRole } from "@backend/definitions"; import type { Song, Setlist, SongEntity, SetlistEntity } from '@backend/models'; @@ -425,6 +425,21 @@ provide(songsCollectionKey, songsCollection); provide(setlistCollectionKey, setlistCollection); provide(clientKey, client); +function bufferAndDefer(handler:(buffer:T[])=>void, deferredCallDelay:number = 20): (doc:T)=>void { + var buffer:T[] = []; + return (doc:T) => { + buffer.push(doc); + if (buffer.length == 1) { + // only schedule one deferred call to handler if this is the first item in the buffer to avoid multiple calls + setTimeout(() => { + handler(buffer); + // clear buffer after handling the chunk of items + buffer = []; + }, deferredCallDelay); + } + } +} + const init = async () => { client.value = amberClient() .withPath('/amber') @@ -453,29 +468,39 @@ const init = async () => { songsCollection.value = collectionApi.getCollection('songs'); setlistCollection.value = collectionApi.getCollection('setlists'); - songsCollection.value.subscribe(0, (doc) => { - let existing = songs.value.find(s => s.id === doc.id); - if (existing) { - existing.entity = doc.data; - existing.changeNumber = doc.change_number; - } else { + songsCollection.value.subscribe(0, bufferAndDefer>((docs) => { + let documentsToAdd: CollectionDocument[] = []; + for (const doc of docs) { + let existing = songs.value.find(s => s.id === doc.id); + if (existing) { + existing.entity = doc.data; + existing.changeNumber = doc.change_number; + } else { + documentsToAdd.push(doc); + } + } + for (const doc of documentsToAdd) { songs.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); } - - }, (docDeletedId) => { + }), (docDeletedId) => { songs.value = songs.value.filter(s => s.id !== docDeletedId); }); - setlistCollection.value.subscribe(0, (doc) => { - let existing = setlists.value.find(s => s.id === doc.id); - if (existing) { - existing.entity = doc.data; - existing.changeNumber = doc.change_number; - } else { + setlistCollection.value.subscribe(0, bufferAndDefer>((docs) => { + let documentsToAdd: CollectionDocument[] = []; + for (const doc of docs) { + let existing = setlists.value.find(s => s.id === doc.id); + if (existing) { + existing.entity = doc.data; + existing.changeNumber = doc.change_number; + } else { + documentsToAdd.push(doc); + } + } + for (const doc of documentsToAdd) { setlists.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); } - - }, (docDeletedId) => { + }), (docDeletedId) => { setlists.value = setlists.value.filter(s => s.id !== docDeletedId); }); From 1508675602ed5d1bf6f3b629bf6b3c24d7b69637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sat, 19 Sep 2026 22:46:47 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9E=95=EF=B8=8F=20Add=20document=20maps?= =?UTF-8?q?=20for=20songs=20and=20setlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.vue | 44 +++++++++++++++++++------------------------ frontend/src/utils.ts | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 919540a..7b3d62f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -232,6 +232,7 @@ import SongSet from '@/modals/SongSet.vue'; import { amberClient, type AmberClient, type UserInTenant, type AmberCollections, type AmberCollection, type UserInfo, type CollectionDocument } from 'amber-client'; import { SongTag, can, UserRole } from "@backend/definitions"; import type { Song, Setlist, SongEntity, SetlistEntity } from '@backend/models'; +import { bufferAndDefer } from '@/utils.js'; // icons import { @@ -397,7 +398,9 @@ const stayLoggedIn = ref(true); // Data const songs = ref([]); +const songsById = new Map(); // For song existence lookup const setlists = ref([]); +const setlistsById = new Map(); // For setlist existence lookup const users = ref>({}); // { [user id]: user info object } const tags = Object.values(SongTag); @@ -425,21 +428,6 @@ provide(songsCollectionKey, songsCollection); provide(setlistCollectionKey, setlistCollection); provide(clientKey, client); -function bufferAndDefer(handler:(buffer:T[])=>void, deferredCallDelay:number = 20): (doc:T)=>void { - var buffer:T[] = []; - return (doc:T) => { - buffer.push(doc); - if (buffer.length == 1) { - // only schedule one deferred call to handler if this is the first item in the buffer to avoid multiple calls - setTimeout(() => { - handler(buffer); - // clear buffer after handling the chunk of items - buffer = []; - }, deferredCallDelay); - } - } -} - const init = async () => { client.value = amberClient() .withPath('/amber') @@ -469,38 +457,44 @@ const init = async () => { setlistCollection.value = collectionApi.getCollection('setlists'); songsCollection.value.subscribe(0, bufferAndDefer>((docs) => { - let documentsToAdd: CollectionDocument[] = []; + const documentsToAdd: Song[] = []; for (const doc of docs) { - let existing = songs.value.find(s => s.id === doc.id); + const existing = songsById.get(doc.id); if (existing) { existing.entity = doc.data; existing.changeNumber = doc.change_number; } else { - documentsToAdd.push(doc); + const song: Song = { id: doc.id, entity: doc.data, changeNumber: doc.change_number }; + songsById.set(doc.id, song); + documentsToAdd.push(song); } } - for (const doc of documentsToAdd) { - songs.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); + if (documentsToAdd.length > 0) { + songs.value.push(...documentsToAdd); } }), (docDeletedId) => { + songsById.delete(docDeletedId); songs.value = songs.value.filter(s => s.id !== docDeletedId); }); setlistCollection.value.subscribe(0, bufferAndDefer>((docs) => { - let documentsToAdd: CollectionDocument[] = []; + const documentsToAdd: Setlist[] = []; for (const doc of docs) { - let existing = setlists.value.find(s => s.id === doc.id); + const existing = setlistsById.get(doc.id); if (existing) { existing.entity = doc.data; existing.changeNumber = doc.change_number; } else { - documentsToAdd.push(doc); + const setlist: Setlist = { id: doc.id, entity: doc.data, changeNumber: doc.change_number }; + setlistsById.set(doc.id, setlist); + documentsToAdd.push(setlist); } } - for (const doc of documentsToAdd) { - setlists.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number }); + if (documentsToAdd.length > 0) { + setlists.value.push(...documentsToAdd); } }), (docDeletedId) => { + setlistsById.delete(docDeletedId); setlists.value = setlists.value.filter(s => s.id !== docDeletedId); }); diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 3cd6f0a..fd0a1e1 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -22,6 +22,24 @@ const isChordLine = (line: string): boolean => { const isSlide = (entry: SetlistEntry | SetlistPresentationEntry): entry is SetlistSlide => !('id' in entry) && !('slug' in entry); +// batch values arriving in quick succession into a single handler call, deferredCallDelay ms after the +// first value of a burst arrives - collapses rapid-fire callbacks (e.g. one per network message) into one +// downstream update per burst instead of one per value +function bufferAndDefer(handler: (buffer: T[]) => void, deferredCallDelay: number = 20): (value: T) => void { + let buffer: T[] = []; + return (value: T) => { + buffer.push(value); + if (buffer.length == 1) { + // only schedule one deferred call to handler if this is the first item in the buffer to avoid multiple calls + setTimeout(() => { + handler(buffer); + // clear buffer after handling the chunk of items + buffer = []; + }, deferredCallDelay); + } + } +} + // grow/shrink the font size of every
 inside every .present element as large as possible while
 // still fitting its parent's width and, per .present group, the viewport's height.
 // Non-wrapping text (songs) and wrapping text (slides) are taken into account.
@@ -503,6 +521,7 @@ export {
   keyScale,
   isChordLine,
   isSlide,
+  bufferAndDefer,
   maximizePresentFontsize,
   parsedContent,
   download,

From 32ebe1b6fcd56df19cf85b03cc3d8abfd01c5084 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andreas=20M=C3=BCller?= 
Date: Sat, 19 Sep 2026 22:46:58 +0200
Subject: [PATCH 3/3] =?UTF-8?q?=E2=9E=95=EF=B8=8F=20Add=20tests=20for=20bu?=
 =?UTF-8?q?fferAndDefer?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 frontend/tests/utils.test.ts | 65 +++++++++++++++++++++++++++++++++++-
 1 file changed, 64 insertions(+), 1 deletion(-)

diff --git a/frontend/tests/utils.test.ts b/frontend/tests/utils.test.ts
index ce79566..c2abf74 100644
--- a/frontend/tests/utils.test.ts
+++ b/frontend/tests/utils.test.ts
@@ -1,6 +1,7 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import type { SongEntity } from '@backend/models';
 import {
+	bufferAndDefer,
 	firstParam,
 	humanDate,
 	humanFileSize,
@@ -14,6 +15,68 @@ import {
 	urlify,
 } from '@/utils';
 
+describe('bufferAndDefer', () => {
+	beforeEach(() => {
+		vi.useFakeTimers();
+	});
+
+	afterEach(() => {
+		vi.useRealTimers();
+	});
+
+	it('defers the handler call until after the delay', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+
+	it('batches values arriving within the delay window into a single call', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		push('b');
+		push('c');
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(1);
+		expect(handler).toHaveBeenCalledWith(['a', 'b', 'c']);
+	});
+
+	it('starts a new batch once the previous one has flushed', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 20);
+		push('a');
+		vi.advanceTimersByTime(20);
+		push('b');
+		vi.advanceTimersByTime(20);
+		expect(handler).toHaveBeenCalledTimes(2);
+		expect(handler).toHaveBeenNthCalledWith(1, ['a']);
+		expect(handler).toHaveBeenNthCalledWith(2, ['b']);
+	});
+
+	it('defaults the delay to 20ms', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler);
+		push('a');
+		vi.advanceTimersByTime(19);
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(1);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+
+	it('respects a custom delay', () => {
+		const handler = vi.fn();
+		const push = bufferAndDefer(handler, 100);
+		push('a');
+		vi.advanceTimersByTime(99);
+		expect(handler).not.toHaveBeenCalled();
+		vi.advanceTimersByTime(1);
+		expect(handler).toHaveBeenCalledTimes(1);
+	});
+});
+
 describe('isChordLine', () => {
 	it('rejects empty and whitespace-only lines', () => {
 		expect(isChordLine('')).toBe(false);