diff --git a/src/lib/components/schedule/BlockModal.svelte b/src/lib/components/schedule/BlockModal.svelte new file mode 100644 index 0000000..352aece --- /dev/null +++ b/src/lib/components/schedule/BlockModal.svelte @@ -0,0 +1,353 @@ + + +{#if open} + +{/if} diff --git a/src/lib/components/schedule/BlockModal.svelte.spec.ts b/src/lib/components/schedule/BlockModal.svelte.spec.ts new file mode 100644 index 0000000..507d062 --- /dev/null +++ b/src/lib/components/schedule/BlockModal.svelte.spec.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/src/lib/components/schedule/DaySchedule.svelte b/src/lib/components/schedule/DaySchedule.svelte new file mode 100644 index 0000000..c9dc434 --- /dev/null +++ b/src/lib/components/schedule/DaySchedule.svelte @@ -0,0 +1,147 @@ + + +
+ + + +
diff --git a/src/lib/components/schedule/ProclamationBrowser.svelte b/src/lib/components/schedule/ProclamationBrowser.svelte new file mode 100644 index 0000000..92f0dc0 --- /dev/null +++ b/src/lib/components/schedule/ProclamationBrowser.svelte @@ -0,0 +1,211 @@ + + +
+
+ {#if categories.length === 0} +

No categories available.

+ {:else} +
    + {#each view as categoryView (categoryView.category.id)} + {@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)} +
  • + + + {#if isCategoryOpen} +
    + {#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0} +

    + No proclamations in this category. +

    + {/if} + + {#each categoryView.groups as groupView (groupView.group.id)} + {@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)} +
    + + {#if isGroupOpen} +
      + {#each groupView.items as proclamation (proclamation.id)} +
    • + +
    • + {/each} +
    + {/if} +
    + {/each} + + {#if categoryView.ungrouped.length > 0} +
    +
      + {#each categoryView.ungrouped as proclamation (proclamation.id)} +
    • + +
    • + {/each} +
    +
    + {/if} +
    + {/if} +
  • + {/each} +
+ {/if} +
+ +
+

+ Selected ({selectedTitles.length}) +

+ {#if selectedTitles.length === 0} +

No proclamations selected.

+ {:else} +
    + {#each selectedTitles as title, index (selectedIds[index])} +
  • {title}
  • + {/each} +
+ {/if} +
+
diff --git a/src/lib/components/schedule/ProclamationBrowser.svelte.spec.ts b/src/lib/components/schedule/ProclamationBrowser.svelte.spec.ts new file mode 100644 index 0000000..dc28bbe --- /dev/null +++ b/src/lib/components/schedule/ProclamationBrowser.svelte.spec.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/src/routes/(protected)/(user)/schedule/+page.server.ts b/src/routes/(protected)/(user)/schedule/+page.server.ts new file mode 100644 index 0000000..adad669 --- /dev/null +++ b/src/routes/(protected)/(user)/schedule/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/(protected)/(user)/schedule/+page.svelte b/src/routes/(protected)/(user)/schedule/+page.svelte new file mode 100644 index 0000000..c6ef768 --- /dev/null +++ b/src/routes/(protected)/(user)/schedule/+page.svelte @@ -0,0 +1,95 @@ + + +
+
+
+

+ Schedule +

+

+ Build your daily listening schedule. Up to 4 blocks per day. +

+
+ + {data.blocks.length} of 4 blocks + +
+ + {#if successMessage} +
+ {successMessage} +
+ {/if} + +
+ +
+ + +
diff --git a/src/routes/(protected)/(user)/schedule/page.server.spec.ts b/src/routes/(protected)/(user)/schedule/page.server.spec.ts new file mode 100644 index 0000000..5adb727 --- /dev/null +++ b/src/routes/(protected)/(user)/schedule/page.server.spec.ts @@ -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; +const listBlocksMock = listBlocks as ReturnType; +const createBlockMock = createBlock as ReturnType; +const deleteBlockMock = deleteBlock as ReturnType; + +const locals = createLocals(mockUser() as never); + +beforeEach(() => { + vi.clearAllMocks(); + (prisma.proclamationCategory.findMany as ReturnType).mockResolvedValue([ + { id: 2, name: 'Physical' } + ]); + (prisma.proclamationGroup.findMany as ReturnType).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: '

secret

' + } + } + ] + } + ]); + + const result = (await load({ locals } as never)) as { + blocks: { id: string; proclamations: Record[] }[]; + }; + + 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(); + }); +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 55e2185..1f04612 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -129,6 +129,12 @@ > Dashboard + + Schedule + Dashboard + + Schedule +