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,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}