Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 38 additions & 19 deletions frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,10 @@ 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';
import { bufferAndDefer } from '@/utils.js';

// icons
import {
Expand Down Expand Up @@ -397,7 +398,9 @@ const stayLoggedIn = ref(true);

// Data
const songs = ref<Song[]>([]);
const songsById = new Map<string, Song>(); // For song existence lookup
const setlists = ref<Setlist[]>([]);
const setlistsById = new Map<string, Setlist>(); // For setlist existence lookup
const users = ref<Record<string, UserInfo>>({}); // { [user id]: user info object }
const tags = Object.values(SongTag);

Expand Down Expand Up @@ -453,29 +456,45 @@ 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 {
songs.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number });
songsCollection.value.subscribe(0, bufferAndDefer<CollectionDocument<SongEntity>>((docs) => {
const documentsToAdd: Song[] = [];
for (const doc of docs) {
const existing = songsById.get(doc.id);
if (existing) {
existing.entity = doc.data;
existing.changeNumber = doc.change_number;
} else {
const song: Song = { id: doc.id, entity: doc.data, changeNumber: doc.change_number };
songsById.set(doc.id, song);
documentsToAdd.push(song);
}
}

}, (docDeletedId) => {
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, (doc) => {
let existing = setlists.value.find(s => s.id === doc.id);
if (existing) {
existing.entity = doc.data;
existing.changeNumber = doc.change_number;
} else {
setlists.value.push({ id: doc.id, entity: doc.data, changeNumber: doc.change_number });
setlistCollection.value.subscribe(0, bufferAndDefer<CollectionDocument<SetlistEntity>>((docs) => {
const documentsToAdd: Setlist[] = [];
for (const doc of docs) {
const existing = setlistsById.get(doc.id);
if (existing) {
existing.entity = doc.data;
existing.changeNumber = doc.change_number;
} else {
const setlist: Setlist = { id: doc.id, entity: doc.data, changeNumber: doc.change_number };
setlistsById.set(doc.id, setlist);
documentsToAdd.push(setlist);
}
}

}, (docDeletedId) => {
if (documentsToAdd.length > 0) {
setlists.value.push(...documentsToAdd);
}
}), (docDeletedId) => {
setlistsById.delete(docDeletedId);
setlists.value = setlists.value.filter(s => s.id !== docDeletedId);
});

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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 <pre> 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.
Expand Down Expand Up @@ -503,6 +521,7 @@ export {
keyScale,
isChordLine,
isSlide,
bufferAndDefer,
maximizePresentFontsize,
parsedContent,
download,
Expand Down
65 changes: 64 additions & 1 deletion frontend/tests/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand Down
Loading