Add schedule page with block editor and proclamation browser

This commit is contained in:
NeilClack
2026-09-11 18:16:06 -05:00
parent 545fb22910
commit 5f4116b8e8
9 changed files with 1280 additions and 0 deletions
@@ -0,0 +1,353 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import { MINUTE_STEP, formatTime, validateBlock, type BlockSibling } from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let endHour = $state('10');
let endMinute = $state('0');
let selectedIds = $state<string[]>([]);
let error = $state('');
const hourOptions = Array.from({ length: 24 }, (_, i) => String(i));
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));
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
startHour = String(Math.floor(initialStartMinutes / 60));
startMinute = String(initialStartMinutes % 60);
endHour = String(Math.floor(initialEndMinutes / 60));
endMinute = String(initialEndMinutes % 60);
selectedIds = [...initialProclamationIds];
error = '';
step = 'details';
});
const startMinutes = $derived((Number(startHour) * 60 + Number(startMinute)) | 0);
const endMinutes = $derived((Number(endHour) * 60 + Number(endMinute)) | 0);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#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.padStart(2, '0')}
</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>
</div>
</div>
<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.padStart(2, '0')}
</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>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div>
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Back
</button>
{:else if mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
{#if step === 'details'}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,110 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
describe('BlockModal', () => {
it('uses separate hour and minute selects 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();
});
it('pre-fills the selects 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');
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
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();
});
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');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,147 @@
<script lang="ts">
import { MINUTES_PER_DAY, SNAP_MINUTES, formatTime, snapMinutes } from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
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
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blocks as block (block.id)}
{@const top = (block.startMinutes / MINUTES_PER_DAY) * 100}
{@const height = ((block.endMinutes - block.startMinutes) / MINUTES_PER_DAY) * 100}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(block);
}}
aria-label={`Block ${formatTime(block.startMinutes)} to ${formatTime(block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${top}%;height:${height}%`}
>
<span class="block font-semibold">
{formatTime(block.startMinutes)} – {formatTime(block.endMinutes)}
</span>
<span class="block truncate">
{block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
@@ -0,0 +1,89 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
}
} catch {
proclamationIds = [];
}
}
return { startMinutes, endMinutes, proclamationIds };
}
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
// Content is deliberately never selected: the portal only surfaces
// title/category/group.
prisma.proclamation.findMany({
select: { id: true, name: true, categoryId: true, groupId: true },
orderBy: { name: 'asc' }
})
]);
const blockViews = blocks.map((block) => ({
id: block.id,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
return { blocks: blockViews, categories, groups, proclamations };
};
export const actions: Actions = {
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(locals.user.id, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const id = (await request.formData()).get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(locals.user.id, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
}
};
@@ -0,0 +1,95 @@
<script lang="ts">
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let successMessage = $state('');
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
modalOpen = true;
}
function openEdit(block: {
id: string;
startMinutes: number;
endMinutes: number;
proclamations: { id: string }[];
}) {
modalMode = 'edit';
editingId = block.id;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
</script>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{data.blocks.length} of 4 blocks
</span>
</div>
{#if successMessage}
<div
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
</div>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
</div>
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never);
beforeEach(() => {
vi.clearAllMocks();
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, name: 'Physical' }
]);
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
select: { id: true, name: true, categoryId: true, groupId: true }
})
);
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
{
id: 'b1',
startMinutes: 360,
endMinutes: 480,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
]
}
]);
const result = (await load({ locals } as never)) as {
blocks: { id: string; proclamations: Record<string, unknown>[] }[];
};
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1
});
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
});
});
describe('schedule actions', () => {
it('createBlock rejects unauthenticated requests', async () => {
await expect(
actions.createBlock!({
request: createFormRequest({}),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('createBlock surfaces the service error', async () => {
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.status).toBe(400);
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
}
});
it('createBlock parses the payload and succeeds', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2'])
}),
locals
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2']
});
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Block not found.' });
}
expect(deleteBlockMock).not.toHaveBeenCalled();
});
});
+13
View File
@@ -129,6 +129,12 @@
>
Dashboard
</a>
<a
href={resolve('/schedule')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a
href={resolve('/profile')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
@@ -383,6 +389,13 @@
>
Dashboard
</a>
<a
href={resolve('/schedule')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a
href={resolve('/profile')}
onclick={closeMenu}