Add Enable Dynamic Audio option to schedule blocks

This commit is contained in:
NeilClack
2026-09-11 18:41:53 -05:00
parent 468a65a688
commit 96a1342c10
9 changed files with 126 additions and 4 deletions
@@ -26,6 +26,7 @@
initialStartMinutes = 9 * 60, initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60, initialEndMinutes = 10 * 60,
initialProclamationIds = [], initialProclamationIds = [],
initialDynamicAudio = false,
categories = [], categories = [],
groups = [], groups = [],
proclamations = [], proclamations = [],
@@ -42,6 +43,7 @@
initialStartMinutes?: number; initialStartMinutes?: number;
initialEndMinutes?: number; initialEndMinutes?: number;
initialProclamationIds?: string[]; initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
categories?: BrowserCategory[]; categories?: BrowserCategory[];
groups?: BrowserGroup[]; groups?: BrowserGroup[];
proclamations?: BrowserProclamation[]; proclamations?: BrowserProclamation[];
@@ -61,6 +63,7 @@
let endMinute = $state('0'); let endMinute = $state('0');
let endPeriod = $state<'AM' | 'PM'>('AM'); let endPeriod = $state<'AM' | 'PM'>('AM');
let selectedIds = $state<string[]>([]); let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(false);
let error = $state(''); let error = $state('');
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1)); const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
@@ -98,6 +101,7 @@
endMinute = String(end.minute); endMinute = String(end.minute);
endPeriod = end.period; endPeriod = end.period;
selectedIds = [...initialProclamationIds]; selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = ''; error = '';
step = 'details'; step = 'details';
}); });
@@ -209,6 +213,7 @@
<input type="hidden" name="startMinutes" value={startMinutes} /> <input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} /> <input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} /> <input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5"> <div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error} {#if error}
@@ -313,6 +318,39 @@
</button> </button>
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
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.
</span>
</span>
</div>
</div> </div>
<div <div
@@ -159,4 +159,35 @@ describe('BlockModal', () => {
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.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');
});
}); });
@@ -12,6 +12,7 @@
id: string; id: string;
startMinutes: number; startMinutes: number;
endMinutes: number; endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[]; proclamations: { id: string; name: string }[];
}; };
+2
View File
@@ -17,6 +17,8 @@ export type BlockInput = {
startMinutes: number; startMinutes: number;
endMinutes: number; endMinutes: number;
proclamationIds: string[]; 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 }; export type ValidationResult = { ok: true } | { ok: false; error: string };
+15
View File
@@ -90,6 +90,7 @@ describe('createBlock', () => {
scheduleId: 's1', scheduleId: 's1',
startMinutes: validInput.startMinutes, startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes, endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: { proclamations: {
create: [ create: [
{ proclamationId: 'p1', sortOrder: 0 }, { 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 () => { it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([ blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 }, { id: 'a', startMinutes: 0, endMinutes: 60 },
@@ -154,6 +168,7 @@ describe('updateBlock', () => {
data: { data: {
startMinutes: validInput.startMinutes, startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes, endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: { proclamations: {
deleteMany: {}, deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }] create: [{ proclamationId: 'p1', sortOrder: 0 }]
+2
View File
@@ -67,6 +67,7 @@ export async function createBlock(userId: string, input: BlockInput): Promise<Bl
scheduleId: schedule.id, scheduleId: schedule.id,
startMinutes: input.startMinutes, startMinutes: input.startMinutes,
endMinutes: input.endMinutes, endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: { proclamations: {
create: proclamationIds.map((proclamationId, index) => ({ create: proclamationIds.map((proclamationId, index) => ({
proclamationId, proclamationId,
@@ -104,6 +105,7 @@ export async function updateBlock(
data: { data: {
startMinutes: input.startMinutes, startMinutes: input.startMinutes,
endMinutes: input.endMinutes, endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: { proclamations: {
deleteMany: {}, deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({ create: proclamationIds.map((proclamationId, index) => ({
@@ -18,7 +18,8 @@ function parseInput(formData: FormData) {
proclamationIds = []; proclamationIds = [];
} }
} }
return { startMinutes, endMinutes, proclamationIds }; const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
} }
export const load: PageServerLoad = async ({ locals }) => { export const load: PageServerLoad = async ({ locals }) => {
@@ -48,6 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
id: block.id, id: block.id,
startMinutes: block.startMinutes, startMinutes: block.startMinutes,
endMinutes: block.endMinutes, endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({ proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id, id: entry.proclamation.id,
name: entry.proclamation.name, name: entry.proclamation.name,
@@ -11,6 +11,7 @@
let initialStart = $state(9 * 60); let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60); let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]); let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(false);
let successMessage = $state(''); let successMessage = $state('');
let closeTimer: ReturnType<typeof setTimeout> | undefined; let closeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -20,6 +21,7 @@
initialStart = startMinutes; initialStart = startMinutes;
initialEnd = endMinutes; initialEnd = endMinutes;
initialIds = []; initialIds = [];
initialDynamicAudio = false;
modalOpen = true; modalOpen = true;
} }
@@ -27,6 +29,7 @@
id: string; id: string;
startMinutes: number; startMinutes: number;
endMinutes: number; endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string }[]; proclamations: { id: string }[];
}) { }) {
modalMode = 'edit'; modalMode = 'edit';
@@ -34,6 +37,7 @@
initialStart = block.startMinutes; initialStart = block.startMinutes;
initialEnd = block.endMinutes; initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id); initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true; modalOpen = true;
} }
@@ -85,6 +89,7 @@
initialStartMinutes={initialStart} initialStartMinutes={initialStart}
initialEndMinutes={initialEnd} initialEndMinutes={initialEnd}
initialProclamationIds={initialIds} initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories} categories={data.categories}
groups={data.groups} groups={data.groups}
proclamations={data.proclamations} proclamations={data.proclamations}
@@ -63,6 +63,7 @@ describe('schedule load', () => {
id: 'b1', id: 'b1',
startMinutes: 360, startMinutes: 360,
endMinutes: 480, endMinutes: 480,
dynamicAudio: true,
proclamations: [ proclamations: [
{ {
proclamation: { proclamation: {
@@ -78,9 +79,14 @@ describe('schedule load', () => {
]); ]);
const result = (await load({ locals } as never)) as { const result = (await load({ locals } as never)) as {
blocks: { id: string; proclamations: Record<string, unknown>[] }[]; blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
}; };
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({ expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1', id: 'p1',
name: 'Peace', name: 'Peace',
@@ -127,7 +133,8 @@ describe('schedule actions', () => {
request: createFormRequest({ request: createFormRequest({
startMinutes: '360', startMinutes: '360',
endMinutes: '480', endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']) proclamationIds: JSON.stringify(['p1', 'p2']),
dynamicAudio: 'true'
}), }),
locals locals
} as never); } as never);
@@ -136,10 +143,29 @@ describe('schedule actions', () => {
expect(createBlockMock).toHaveBeenCalledWith('user-1', { expect(createBlockMock).toHaveBeenCalledWith('user-1', {
startMinutes: 360, startMinutes: 360,
endMinutes: 480, 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 () => { it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({ const result = await actions.deleteBlock!({
request: createFormRequest({}), request: createFormRequest({}),