Portal: 12-hour schedule times and dynamicAudio default on
Use TimeInput for Block start/end and show the day timeline axis with AM/PM, so no 24-hour time is displayed in the schedule UI. New Blocks now default dynamicAudio to true in the modal and server-side when the field is absent; existing stored Blocks are untouched.
This commit is contained in:
@@ -2,10 +2,12 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
||||||
|
import TimeInput from '$lib/components/TimeInput.svelte';
|
||||||
import {
|
import {
|
||||||
MINUTE_STEP,
|
|
||||||
formatTime,
|
formatTime,
|
||||||
|
hmToMinutes,
|
||||||
isOvernight,
|
isOvernight,
|
||||||
|
toHm,
|
||||||
validateBlock,
|
validateBlock,
|
||||||
type BlockSibling
|
type BlockSibling
|
||||||
} from '$lib/schedule-utils';
|
} from '$lib/schedule-utils';
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
initialStartMinutes = 9 * 60,
|
initialStartMinutes = 9 * 60,
|
||||||
initialEndMinutes = 10 * 60,
|
initialEndMinutes = 10 * 60,
|
||||||
initialProclamationIds = [],
|
initialProclamationIds = [],
|
||||||
initialDynamicAudio = false,
|
initialDynamicAudio = true,
|
||||||
categories = [],
|
categories = [],
|
||||||
groups = [],
|
groups = [],
|
||||||
proclamations = [],
|
proclamations = [],
|
||||||
@@ -56,58 +58,38 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let step = $state<'details' | 'browse'>('details');
|
let step = $state<'details' | 'browse'>('details');
|
||||||
let startHour = $state('9');
|
// Seeded from the props in the effect below whenever the modal opens.
|
||||||
let startMinute = $state('0');
|
let startMinutes = $state(9 * 60);
|
||||||
let startPeriod = $state<'AM' | 'PM'>('AM');
|
let endMinutes = $state(10 * 60);
|
||||||
let endHour = $state('10');
|
let startValid = $state(true);
|
||||||
let endMinute = $state('0');
|
let endValid = $state(true);
|
||||||
let endPeriod = $state<'AM' | 'PM'>('AM');
|
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
let dynamicAudio = $state(false);
|
let dynamicAudio = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
|
function setStart(hm: string) {
|
||||||
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
|
const minutes = hmToMinutes(hm);
|
||||||
|
if (minutes !== null) startMinutes = minutes;
|
||||||
function minuteOptions(current: string): string[] {
|
|
||||||
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
|
|
||||||
if (base.includes(current)) return base;
|
|
||||||
return [...base, current].sort((a, b) => Number(a) - Number(b));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } {
|
function setEnd(hm: string) {
|
||||||
const hours24 = Math.floor(totalMinutes / 60) % 24;
|
const minutes = hmToMinutes(hm);
|
||||||
return {
|
if (minutes !== null) endMinutes = minutes;
|
||||||
hour: hours24 % 12 === 0 ? 12 : hours24 % 12,
|
|
||||||
minute: totalMinutes % 60,
|
|
||||||
period: hours24 < 12 ? 'AM' : 'PM'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function to24Hour(hour12: string, minute: string, period: 'AM' | 'PM'): number {
|
|
||||||
const hours = (Number(hour12) % 12) + (period === 'PM' ? 12 : 0);
|
|
||||||
return (hours * 60 + Number(minute)) | 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-seed the fields whenever the modal is (re)opened.
|
// Re-seed the fields whenever the modal is (re)opened.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const start = to12Hour(initialStartMinutes);
|
startMinutes = initialStartMinutes;
|
||||||
startHour = String(start.hour);
|
endMinutes = initialEndMinutes;
|
||||||
startMinute = String(start.minute);
|
startValid = true;
|
||||||
startPeriod = start.period;
|
endValid = true;
|
||||||
const end = to12Hour(initialEndMinutes);
|
|
||||||
endHour = String(end.hour);
|
|
||||||
endMinute = String(end.minute);
|
|
||||||
endPeriod = end.period;
|
|
||||||
selectedIds = [...initialProclamationIds];
|
selectedIds = [...initialProclamationIds];
|
||||||
dynamicAudio = initialDynamicAudio;
|
dynamicAudio = initialDynamicAudio;
|
||||||
error = '';
|
error = '';
|
||||||
step = 'details';
|
step = 'details';
|
||||||
});
|
});
|
||||||
|
|
||||||
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
|
|
||||||
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
|
|
||||||
const overnight = $derived(
|
const overnight = $derived(
|
||||||
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
|
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
|
||||||
);
|
);
|
||||||
@@ -122,6 +104,7 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function validate(): string | null {
|
function validate(): string | null {
|
||||||
|
if (!startValid || !endValid) return 'Enter a valid start and end time.';
|
||||||
const result = validateBlock(
|
const result = validateBlock(
|
||||||
{ startMinutes, endMinutes, proclamationIds: selectedIds },
|
{ startMinutes, endMinutes, proclamationIds: selectedIds },
|
||||||
siblings,
|
siblings,
|
||||||
@@ -228,81 +211,19 @@
|
|||||||
{#if step === 'details'}
|
{#if step === 'details'}
|
||||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<TimeInput
|
||||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
label="Start time"
|
||||||
>Start time</span
|
value={toHm(startMinutes)}
|
||||||
>
|
onchange={setStart}
|
||||||
<div class="flex items-center gap-2">
|
onvalidity={(valid) => (startValid = valid)}
|
||||||
<select
|
/>
|
||||||
aria-label="Start hour"
|
|
||||||
onchange={(e) => (startHour = e.currentTarget.value)}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each hourOptions as hour (hour)}
|
|
||||||
<option value={hour} selected={startHour === hour}>{hour}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<span class="text-slate-400">:</span>
|
|
||||||
<select
|
|
||||||
aria-label="Start minute"
|
|
||||||
onchange={(e) => (startMinute = e.currentTarget.value)}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each minuteOptions(startMinute) as minute (minute)}
|
|
||||||
<option value={minute} selected={startMinute === minute}>
|
|
||||||
{minute.padStart(2, '0')}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="Start period"
|
|
||||||
onchange={(e) => (startPeriod = e.currentTarget.value as 'AM' | 'PM')}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each periodOptions as period (period)}
|
|
||||||
<option value={period} selected={startPeriod === period}>{period}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<TimeInput
|
||||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
label="End time"
|
||||||
>End time</span
|
value={toHm(endMinutes)}
|
||||||
>
|
onchange={setEnd}
|
||||||
<div class="flex items-center gap-2">
|
onvalidity={(valid) => (endValid = valid)}
|
||||||
<select
|
/>
|
||||||
aria-label="End hour"
|
|
||||||
onchange={(e) => (endHour = e.currentTarget.value)}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each hourOptions as hour (hour)}
|
|
||||||
<option value={hour} selected={endHour === hour}>{hour}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<span class="text-slate-400">:</span>
|
|
||||||
<select
|
|
||||||
aria-label="End minute"
|
|
||||||
onchange={(e) => (endMinute = e.currentTarget.value)}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each minuteOptions(endMinute) as minute (minute)}
|
|
||||||
<option value={minute} selected={endMinute === minute}>
|
|
||||||
{minute.padStart(2, '0')}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
aria-label="End period"
|
|
||||||
onchange={(e) => (endPeriod = e.currentTarget.value as 'AM' | 'PM')}
|
|
||||||
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{#each periodOptions as period (period)}
|
|
||||||
<option value={period} selected={endPeriod === period}>{period}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
|||||||
@@ -46,53 +46,50 @@ function submit() {
|
|||||||
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
|
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function typeTime(name: string, text: string) {
|
||||||
|
const input = (await page.getByRole('combobox', { name }).element()) as HTMLInputElement;
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('BlockModal', () => {
|
describe('BlockModal', () => {
|
||||||
it('uses separate hour and minute selects for start and end', async () => {
|
it('uses a 12-hour TimeInput for start and end', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
|
||||||
await expect
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
|
||||||
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pre-fills the selects from the initial times', async () => {
|
it('pre-fills the fields from the initial times', async () => {
|
||||||
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
|
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
|
||||||
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7');
|
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('7:30 AM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a 12-hour clock with an AM/PM select for each time', async () => {
|
it('shows a 12-hour clock with AM/PM', async () => {
|
||||||
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
|
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
|
||||||
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9');
|
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('9:00 PM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('12');
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^End period$/ })).toHaveValue('AM');
|
|
||||||
|
|
||||||
// No 24-hour option exists any more.
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('combobox', { name: /^Start hour$/ }))
|
|
||||||
.not.toHaveValue('21');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recomputes the time when the AM/PM switch changes', async () => {
|
it('recomputes the time from typed input', async () => {
|
||||||
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
|
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
|
||||||
|
|
||||||
const period = (await page
|
await typeTime('End time', '10:00 PM');
|
||||||
.getByRole('combobox', { name: /^End period$/ })
|
|
||||||
.element()) as HTMLSelectElement;
|
|
||||||
period.value = 'PM';
|
|
||||||
period.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
|
|
||||||
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
|
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows validation feedback for unparseable input', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
await typeTime('Start time', '25:00');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the review times and selected proclamation titles', async () => {
|
it('shows the review times and selected proclamation titles', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
@@ -113,7 +110,6 @@ describe('BlockModal', () => {
|
|||||||
initialEndMinutes: 9 * 60
|
initialEndMinutes: 9 * 60
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
submit();
|
submit();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
@@ -124,7 +120,6 @@ describe('BlockModal', () => {
|
|||||||
it('requires at least one proclamation', async () => {
|
it('requires at least one proclamation', async () => {
|
||||||
mount({ initialProclamationIds: [] });
|
mount({ initialProclamationIds: [] });
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
submit();
|
submit();
|
||||||
|
|
||||||
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
|
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
|
||||||
@@ -133,7 +128,6 @@ describe('BlockModal', () => {
|
|||||||
it('does not show an error for a valid selection', async () => {
|
it('does not show an error for a valid selection', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
const result = submit();
|
const result = submit();
|
||||||
|
|
||||||
expect(typeof result).toBe('function');
|
expect(typeof result).toBe('function');
|
||||||
@@ -165,21 +159,32 @@ describe('BlockModal', () => {
|
|||||||
|
|
||||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
await expect.element(checkbox).toBeInTheDocument();
|
await expect.element(checkbox).toBeInTheDocument();
|
||||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByText(/Dynamic audio will be enabled for this block/))
|
.element(page.getByText(/Dynamic audio will be enabled for this block/))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults dynamic audio on for new blocks', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
|
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
|
||||||
mount({ initialDynamicAudio: true });
|
mount({ initialDynamicAudio: true });
|
||||||
|
|
||||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
await expect.element(checkbox).toBeInTheDocument();
|
|
||||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves dynamic audio off when editing a block that has it disabled', async () => {
|
||||||
|
mount({ initialDynamicAudio: false });
|
||||||
|
|
||||||
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles the dynamic audio flag in the submitted payload', async () => {
|
it('toggles the dynamic audio flag in the submitted payload', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
@@ -188,6 +193,6 @@ describe('BlockModal', () => {
|
|||||||
(checkbox.element() as HTMLInputElement).click();
|
(checkbox.element() as HTMLInputElement).click();
|
||||||
|
|
||||||
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
|
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
|
||||||
await expect.poll(() => hidden.value).toBe('true');
|
await expect.poll(() => hidden.value).toBe('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
|
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
|
||||||
style={`top:${(hour / 24) * 100}%`}
|
style={`top:${(hour / 24) * 100}%`}
|
||||||
>
|
>
|
||||||
{String(hour).padStart(2, '0')}:00
|
{formatTime(hour * 60)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe('createBlock', () => {
|
|||||||
scheduleId: 's1',
|
scheduleId: 's1',
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
dynamicAudio: false,
|
dynamicAudio: true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: [
|
create: [
|
||||||
{ proclamationId: 'p1', sortOrder: 0 },
|
{ proclamationId: 'p1', sortOrder: 0 },
|
||||||
@@ -101,6 +101,32 @@ describe('createBlock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults dynamic audio on when the field is absent', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
|
|
||||||
|
await createBlock('u1', validInput);
|
||||||
|
|
||||||
|
expect(blockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ dynamicAudio: true })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists dynamic audio off when the field is explicitly false', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
|
|
||||||
|
await createBlock('u1', { ...validInput, dynamicAudio: false });
|
||||||
|
|
||||||
|
expect(blockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ dynamicAudio: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('persists the dynamic audio flag', async () => {
|
it('persists the dynamic audio flag', async () => {
|
||||||
blockFindMany.mockResolvedValue([]);
|
blockFindMany.mockResolvedValue([]);
|
||||||
blockCreate.mockResolvedValue({ id: 'new-block' });
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
@@ -168,7 +194,7 @@ describe('updateBlock', () => {
|
|||||||
data: {
|
data: {
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
dynamicAudio: false,
|
dynamicAudio: true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ function uniqueIds(ids: string[]): string[] {
|
|||||||
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
|
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New Blocks default `dynamicAudio` to true when the field is absent. Existing
|
||||||
|
* stored Blocks are never rewritten by this default.
|
||||||
|
*/
|
||||||
|
function dynamicAudioOrDefault(input: BlockInput): boolean {
|
||||||
|
return input.dynamicAudio !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ensure the user has a Schedule row, creating the singleton on first use. */
|
/** Ensure the user has a Schedule row, creating the singleton on first use. */
|
||||||
export async function getOrCreateSchedule(userId: string) {
|
export async function getOrCreateSchedule(userId: string) {
|
||||||
const existing = await prisma.schedule.findUnique({ where: { userId } });
|
const existing = await prisma.schedule.findUnique({ where: { userId } });
|
||||||
@@ -67,7 +75,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,
|
dynamicAudio: dynamicAudioOrDefault(input),
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
proclamationId,
|
proclamationId,
|
||||||
@@ -105,7 +113,7 @@ export async function updateBlock(
|
|||||||
data: {
|
data: {
|
||||||
startMinutes: input.startMinutes,
|
startMinutes: input.startMinutes,
|
||||||
endMinutes: input.endMinutes,
|
endMinutes: input.endMinutes,
|
||||||
dynamicAudio: input.dynamicAudio === true,
|
dynamicAudio: dynamicAudioOrDefault(input),
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
initialStart = startMinutes;
|
initialStart = startMinutes;
|
||||||
initialEnd = endMinutes;
|
initialEnd = endMinutes;
|
||||||
initialIds = [];
|
initialIds = [];
|
||||||
initialDynamicAudio = false;
|
initialDynamicAudio = true;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user