Add schedule page with block editor and proclamation browser
This commit is contained in:
@@ -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"
|
||||
>
|
||||
×
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user