Dev #1
@@ -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<string[]>([]);
|
||||
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'}
|
||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>Start time</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<TimeInput
|
||||
label="Start time"
|
||||
value={toHm(startMinutes)}
|
||||
onchange={setStart}
|
||||
onvalidity={(valid) => (startValid = valid)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||
>End time</span
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
<TimeInput
|
||||
label="End time"
|
||||
value={toHm(endMinutes)}
|
||||
onchange={setEnd}
|
||||
onvalidity={(valid) => (endValid = valid)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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<Bl
|
||||
scheduleId: schedule.id,
|
||||
startMinutes: input.startMinutes,
|
||||
endMinutes: input.endMinutes,
|
||||
dynamicAudio: input.dynamicAudio === true,
|
||||
dynamicAudio: dynamicAudioOrDefault(input),
|
||||
proclamations: {
|
||||
create: proclamationIds.map((proclamationId, index) => ({
|
||||
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) => ({
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
initialStart = startMinutes;
|
||||
initialEnd = endMinutes;
|
||||
initialIds = [];
|
||||
initialDynamicAudio = false;
|
||||
initialDynamicAudio = true;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user