diff --git a/src/lib/components/schedule/BlockModal.svelte b/src/lib/components/schedule/BlockModal.svelte index ea2c5e7..9e1e263 100644 --- a/src/lib/components/schedule/BlockModal.svelte +++ b/src/lib/components/schedule/BlockModal.svelte @@ -26,6 +26,7 @@ initialStartMinutes = 9 * 60, initialEndMinutes = 10 * 60, initialProclamationIds = [], + initialDynamicAudio = false, categories = [], groups = [], proclamations = [], @@ -42,6 +43,7 @@ initialStartMinutes?: number; initialEndMinutes?: number; initialProclamationIds?: string[]; + initialDynamicAudio?: boolean; categories?: BrowserCategory[]; groups?: BrowserGroup[]; proclamations?: BrowserProclamation[]; @@ -61,6 +63,7 @@ let endMinute = $state('0'); let endPeriod = $state<'AM' | 'PM'>('AM'); let selectedIds = $state([]); + let dynamicAudio = $state(false); let error = $state(''); const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1)); @@ -98,6 +101,7 @@ endMinute = String(end.minute); endPeriod = end.period; selectedIds = [...initialProclamationIds]; + dynamicAudio = initialDynamicAudio; error = ''; step = 'details'; }); @@ -209,6 +213,7 @@ +
{#if error} @@ -313,6 +318,39 @@
+ +
+ + + + + Dynamic audio will be enabled for this block, while this block is playing. + This will override any audio selections you have made on your device while + this block is active. + + +
{ await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument(); }); + + it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => { + mount(); + + const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ }); + await expect.element(checkbox).toBeInTheDocument(); + expect((checkbox.element() as HTMLInputElement).checked).toBe(false); + + await expect + .element(page.getByText(/Dynamic audio will be enabled for this block/)) + .toBeInTheDocument(); + }); + + it('pre-checks dynamic audio when editing a block that has it enabled', async () => { + mount({ initialDynamicAudio: true }); + + const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ }); + await expect.element(checkbox).toBeInTheDocument(); + expect((checkbox.element() as HTMLInputElement).checked).toBe(true); + }); + + it('toggles the dynamic audio flag in the submitted payload', async () => { + mount(); + + const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ }); + await expect.element(checkbox).toBeInTheDocument(); + (checkbox.element() as HTMLInputElement).click(); + + const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement; + await expect.poll(() => hidden.value).toBe('true'); + }); }); diff --git a/src/lib/components/schedule/DaySchedule.svelte b/src/lib/components/schedule/DaySchedule.svelte index 1d1b080..67aea11 100644 --- a/src/lib/components/schedule/DaySchedule.svelte +++ b/src/lib/components/schedule/DaySchedule.svelte @@ -12,6 +12,7 @@ id: string; startMinutes: number; endMinutes: number; + dynamicAudio: boolean; proclamations: { id: string; name: string }[]; }; diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts index be6f9b8..2f16e65 100644 --- a/src/lib/schedule-utils.ts +++ b/src/lib/schedule-utils.ts @@ -17,6 +17,8 @@ export type BlockInput = { startMinutes: number; endMinutes: number; proclamationIds: string[]; + /** When true the kiosk enables dynamic audio for the block's duration. */ + dynamicAudio?: boolean; }; export type ValidationResult = { ok: true } | { ok: false; error: string }; diff --git a/src/lib/server/schedule.spec.ts b/src/lib/server/schedule.spec.ts index 5a5e641..150af0e 100644 --- a/src/lib/server/schedule.spec.ts +++ b/src/lib/server/schedule.spec.ts @@ -90,6 +90,7 @@ describe('createBlock', () => { scheduleId: 's1', startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, + dynamicAudio: false, proclamations: { create: [ { proclamationId: 'p1', sortOrder: 0 }, @@ -100,6 +101,19 @@ describe('createBlock', () => { }); }); + it('persists the dynamic audio flag', async () => { + blockFindMany.mockResolvedValue([]); + blockCreate.mockResolvedValue({ id: 'new-block' }); + + await createBlock('u1', { ...validInput, dynamicAudio: true }); + + expect(blockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ dynamicAudio: true }) + }) + ); + }); + it('rejects a fifth block without creating', async () => { blockFindMany.mockResolvedValue([ { id: 'a', startMinutes: 0, endMinutes: 60 }, @@ -154,6 +168,7 @@ describe('updateBlock', () => { data: { startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, + dynamicAudio: false, proclamations: { deleteMany: {}, create: [{ proclamationId: 'p1', sortOrder: 0 }] diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 3df0e14..48149b7 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -67,6 +67,7 @@ export async function createBlock(userId: string, input: BlockInput): Promise ({ proclamationId, @@ -104,6 +105,7 @@ export async function updateBlock( data: { startMinutes: input.startMinutes, endMinutes: input.endMinutes, + dynamicAudio: input.dynamicAudio === true, proclamations: { deleteMany: {}, create: proclamationIds.map((proclamationId, index) => ({ diff --git a/src/routes/(protected)/(user)/schedule/+page.server.ts b/src/routes/(protected)/(user)/schedule/+page.server.ts index adad669..10359d1 100644 --- a/src/routes/(protected)/(user)/schedule/+page.server.ts +++ b/src/routes/(protected)/(user)/schedule/+page.server.ts @@ -18,7 +18,8 @@ function parseInput(formData: FormData) { proclamationIds = []; } } - return { startMinutes, endMinutes, proclamationIds }; + const dynamicAudio = formData.get('dynamicAudio') === 'true'; + return { startMinutes, endMinutes, proclamationIds, dynamicAudio }; } export const load: PageServerLoad = async ({ locals }) => { @@ -48,6 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => { id: block.id, startMinutes: block.startMinutes, endMinutes: block.endMinutes, + dynamicAudio: block.dynamicAudio, proclamations: block.proclamations.map((entry) => ({ id: entry.proclamation.id, name: entry.proclamation.name, diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte index c6ef768..1f37e36 100644 --- a/src/routes/(protected)/(user)/schedule/+page.svelte +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -11,6 +11,7 @@ let initialStart = $state(9 * 60); let initialEnd = $state(10 * 60); let initialIds = $state([]); + let initialDynamicAudio = $state(false); let successMessage = $state(''); let closeTimer: ReturnType | undefined; @@ -20,6 +21,7 @@ initialStart = startMinutes; initialEnd = endMinutes; initialIds = []; + initialDynamicAudio = false; modalOpen = true; } @@ -27,6 +29,7 @@ id: string; startMinutes: number; endMinutes: number; + dynamicAudio: boolean; proclamations: { id: string }[]; }) { modalMode = 'edit'; @@ -34,6 +37,7 @@ initialStart = block.startMinutes; initialEnd = block.endMinutes; initialIds = block.proclamations.map((proclamation) => proclamation.id); + initialDynamicAudio = block.dynamicAudio; modalOpen = true; } @@ -85,6 +89,7 @@ initialStartMinutes={initialStart} initialEndMinutes={initialEnd} initialProclamationIds={initialIds} + {initialDynamicAudio} categories={data.categories} groups={data.groups} proclamations={data.proclamations} diff --git a/src/routes/(protected)/(user)/schedule/page.server.spec.ts b/src/routes/(protected)/(user)/schedule/page.server.spec.ts index 5adb727..4d6eab0 100644 --- a/src/routes/(protected)/(user)/schedule/page.server.spec.ts +++ b/src/routes/(protected)/(user)/schedule/page.server.spec.ts @@ -63,6 +63,7 @@ describe('schedule load', () => { id: 'b1', startMinutes: 360, endMinutes: 480, + dynamicAudio: true, proclamations: [ { proclamation: { @@ -78,9 +79,14 @@ describe('schedule load', () => { ]); const result = (await load({ locals } as never)) as { - blocks: { id: string; proclamations: Record[] }[]; + blocks: { + id: string; + dynamicAudio: boolean; + proclamations: Record[]; + }[]; }; + expect(result.blocks[0].dynamicAudio).toBe(true); expect(result.blocks[0].proclamations[0]).toEqual({ id: 'p1', name: 'Peace', @@ -127,7 +133,8 @@ describe('schedule actions', () => { request: createFormRequest({ startMinutes: '360', endMinutes: '480', - proclamationIds: JSON.stringify(['p1', 'p2']) + proclamationIds: JSON.stringify(['p1', 'p2']), + dynamicAudio: 'true' }), locals } as never); @@ -136,10 +143,29 @@ describe('schedule actions', () => { expect(createBlockMock).toHaveBeenCalledWith('user-1', { startMinutes: 360, endMinutes: 480, - proclamationIds: ['p1', 'p2'] + proclamationIds: ['p1', 'p2'], + dynamicAudio: true }); }); + it('createBlock defaults dynamic audio to false when the field is absent', async () => { + createBlockMock.mockResolvedValue({ ok: true, id: 'b1' }); + + await actions.createBlock!({ + request: createFormRequest({ + startMinutes: '360', + endMinutes: '480', + proclamationIds: JSON.stringify(['p1']) + }), + locals + } as never); + + expect(createBlockMock).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ dynamicAudio: false }) + ); + }); + it('deleteBlock returns not found without an id', async () => { const result = await actions.deleteBlock!({ request: createFormRequest({}),