diff --git a/src/lib/components/schedule/BlockModal.svelte b/src/lib/components/schedule/BlockModal.svelte index 9e1e263..239e0c0 100644 --- a/src/lib/components/schedule/BlockModal.svelte +++ b/src/lib/components/schedule/BlockModal.svelte @@ -2,10 +2,12 @@ import { enhance } from '$app/forms'; import type { SubmitFunction } from '@sveltejs/kit'; import ProclamationBrowser from './ProclamationBrowser.svelte'; + import TimeInput from '$lib/components/TimeInput.svelte'; import { - MINUTE_STEP, formatTime, + hmToMinutes, isOvernight, + toHm, validateBlock, type BlockSibling } from '$lib/schedule-utils'; @@ -26,7 +28,7 @@ initialStartMinutes = 9 * 60, initialEndMinutes = 10 * 60, initialProclamationIds = [], - initialDynamicAudio = false, + initialDynamicAudio = true, categories = [], groups = [], proclamations = [], @@ -56,58 +58,38 @@ } = $props(); let step = $state<'details' | 'browse'>('details'); - let startHour = $state('9'); - let startMinute = $state('0'); - let startPeriod = $state<'AM' | 'PM'>('AM'); - let endHour = $state('10'); - let endMinute = $state('0'); - let endPeriod = $state<'AM' | 'PM'>('AM'); + // Seeded from the props in the effect below whenever the modal opens. + let startMinutes = $state(9 * 60); + let endMinutes = $state(10 * 60); + let startValid = $state(true); + let endValid = $state(true); let selectedIds = $state([]); - let dynamicAudio = $state(false); + let dynamicAudio = $state(true); let error = $state(''); - const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1)); - const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM']; - - 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 setStart(hm: string) { + const minutes = hmToMinutes(hm); + if (minutes !== null) startMinutes = minutes; } - function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } { - const hours24 = Math.floor(totalMinutes / 60) % 24; - return { - 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; + function setEnd(hm: string) { + const minutes = hmToMinutes(hm); + if (minutes !== null) endMinutes = minutes; } // Re-seed the fields whenever the modal is (re)opened. $effect(() => { if (!open) return; - const start = to12Hour(initialStartMinutes); - startHour = String(start.hour); - startMinute = String(start.minute); - startPeriod = start.period; - const end = to12Hour(initialEndMinutes); - endHour = String(end.hour); - endMinute = String(end.minute); - endPeriod = end.period; + startMinutes = initialStartMinutes; + endMinutes = initialEndMinutes; + startValid = true; + endValid = true; selectedIds = [...initialProclamationIds]; dynamicAudio = initialDynamicAudio; error = ''; step = 'details'; }); - const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod)); - const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod)); const overnight = $derived( startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes }) ); @@ -122,6 +104,7 @@ ); function validate(): string | null { + if (!startValid || !endValid) return 'Enter a valid start and end time.'; const result = validateBlock( { startMinutes, endMinutes, proclamationIds: selectedIds }, siblings, @@ -228,81 +211,19 @@ {#if step === 'details'}
-
- Start time -
- - : - - -
-
+ (startValid = valid)} + /> -
- End time -
- - : - - -
-
+ (endValid = valid)} + />
diff --git a/src/lib/components/schedule/BlockModal.svelte.spec.ts b/src/lib/components/schedule/BlockModal.svelte.spec.ts index 21531df..79fb486 100644 --- a/src/lib/components/schedule/BlockModal.svelte.spec.ts +++ b/src/lib/components/schedule/BlockModal.svelte.spec.ts @@ -46,53 +46,50 @@ function submit() { 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', () => { - it('uses separate hour and minute selects for start and end', async () => { + it('uses a 12-hour TimeInput for start and end', async () => { mount(); - await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument(); - await expect - .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(); + await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument(); + await expect.element(page.getByRole('combobox', { name: 'End time' })).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 }); - await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7'); - await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30'); - await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9'); - await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15'); + await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('7:30 AM'); + await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM'); }); - 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 }); - await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9'); - await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM'); - 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'); + await expect.element(page.getByRole('combobox', { name: 'Start time' })).toHaveValue('9:00 PM'); + await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM'); }); - 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 }); - const period = (await page - .getByRole('combobox', { name: /^End period$/ }) - .element()) as HTMLSelectElement; - period.value = 'PM'; - period.dispatchEvent(new Event('change', { bubbles: true })); + await typeTime('End time', '10:00 PM'); 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 () => { mount(); @@ -113,7 +110,6 @@ describe('BlockModal', () => { initialEndMinutes: 9 * 60 }); - await page.getByRole('combobox', { name: /^Start hour$/ }).element(); submit(); await expect @@ -124,7 +120,6 @@ describe('BlockModal', () => { it('requires at least one proclamation', async () => { mount({ initialProclamationIds: [] }); - await page.getByRole('combobox', { name: /^Start hour$/ }).element(); submit(); 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 () => { mount(); - await page.getByRole('combobox', { name: /^Start hour$/ }).element(); const result = submit(); expect(typeof result).toBe('function'); @@ -165,21 +159,32 @@ describe('BlockModal', () => { 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('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 () => { 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('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 () => { mount(); @@ -188,6 +193,6 @@ describe('BlockModal', () => { (checkbox.element() as HTMLInputElement).click(); const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement; - await expect.poll(() => hidden.value).toBe('true'); + await expect.poll(() => hidden.value).toBe('false'); }); }); diff --git a/src/lib/components/schedule/DaySchedule.svelte b/src/lib/components/schedule/DaySchedule.svelte index 67aea11..f8e9c1c 100644 --- a/src/lib/components/schedule/DaySchedule.svelte +++ b/src/lib/components/schedule/DaySchedule.svelte @@ -104,7 +104,7 @@ class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500" style={`top:${(hour / 24) * 100}%`} > - {String(hour).padStart(2, '0')}:00 + {formatTime(hour * 60)}
{/each}
diff --git a/src/lib/server/schedule.spec.ts b/src/lib/server/schedule.spec.ts index 150af0e..2006c91 100644 --- a/src/lib/server/schedule.spec.ts +++ b/src/lib/server/schedule.spec.ts @@ -90,7 +90,7 @@ describe('createBlock', () => { scheduleId: 's1', startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, - dynamicAudio: false, + dynamicAudio: true, proclamations: { create: [ { 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 () => { blockFindMany.mockResolvedValue([]); blockCreate.mockResolvedValue({ id: 'new-block' }); @@ -168,7 +194,7 @@ describe('updateBlock', () => { data: { startMinutes: validInput.startMinutes, endMinutes: validInput.endMinutes, - dynamicAudio: false, + dynamicAudio: true, proclamations: { deleteMany: {}, create: [{ proclamationId: 'p1', sortOrder: 0 }] diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts index 48149b7..4023d94 100644 --- a/src/lib/server/schedule.ts +++ b/src/lib/server/schedule.ts @@ -25,6 +25,14 @@ function uniqueIds(ids: string[]): string[] { 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. */ export async function getOrCreateSchedule(userId: string) { const existing = await prisma.schedule.findUnique({ where: { userId } }); @@ -67,7 +75,7 @@ export async function createBlock(userId: string, input: BlockInput): Promise ({ proclamationId, @@ -105,7 +113,7 @@ export async function updateBlock( data: { startMinutes: input.startMinutes, endMinutes: input.endMinutes, - dynamicAudio: input.dynamicAudio === true, + dynamicAudio: dynamicAudioOrDefault(input), proclamations: { deleteMany: {}, create: proclamationIds.map((proclamationId, index) => ({ diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte index 1f37e36..0ecc0df 100644 --- a/src/routes/(protected)/(user)/schedule/+page.svelte +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -21,7 +21,7 @@ initialStart = startMinutes; initialEnd = endMinutes; initialIds = []; - initialDynamicAudio = false; + initialDynamicAudio = true; modalOpen = true; }