Dev #1

Merged
NeilClack merged 25 commits from dev into main 2026-09-30 04:29:28 +00:00
6 changed files with 113 additions and 153 deletions
Showing only changes of commit 179df1e252 - Show all commits
+33 -112
View File
@@ -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>
+28 -2
View File
@@ -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 }]
+10 -2
View File
@@ -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;
}