diff --git a/src/lib/schedule-utils.spec.ts b/src/lib/schedule-utils.spec.ts new file mode 100644 index 0000000..6d0fb44 --- /dev/null +++ b/src/lib/schedule-utils.spec.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest'; +import { + findOverlap, + formatTime, + snapMinutes, + toHm, + validateBlock, + type BlockInput, + type BlockSibling +} from './schedule-utils'; + +const input = (overrides: Partial = {}): BlockInput => ({ + startMinutes: 6 * 60, + endMinutes: 8 * 60, + proclamationIds: ['p1'], + ...overrides +}); + +const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({ + id, + startMinutes, + endMinutes +}); + +describe('formatTime', () => { + it('formats 12-hour clock labels', () => { + expect(formatTime(0)).toBe('12:00 AM'); + expect(formatTime(6 * 60)).toBe('6:00 AM'); + expect(formatTime(8 * 60 + 5)).toBe('8:05 AM'); + expect(formatTime(12 * 60)).toBe('12:00 PM'); + expect(formatTime(13 * 60 + 30)).toBe('1:30 PM'); + expect(formatTime(23 * 60 + 59)).toBe('11:59 PM'); + }); +}); + +describe('toHm', () => { + it('formats 24-hour HH:MM strings', () => { + expect(toHm(0)).toBe('00:00'); + expect(toHm(6 * 60)).toBe('06:00'); + expect(toHm(23 * 60 + 59)).toBe('23:59'); + }); +}); + +describe('snapMinutes', () => { + it('rounds to the nearest 15 minutes and clamps to the day', () => { + expect(snapMinutes(0)).toBe(0); + expect(snapMinutes(7)).toBe(0); + expect(snapMinutes(8)).toBe(15); + expect(snapMinutes(370)).toBe(375); + expect(snapMinutes(-10)).toBe(0); + expect(snapMinutes(2000)).toBe(1440); + }); +}); + +describe('findOverlap', () => { + it('returns null for adjacent ranges', () => { + expect( + findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)]) + ).toBeNull(); + }); + + it('returns the sibling for a partial overlap', () => { + const other = sibling(7 * 60, 9 * 60); + expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other); + }); +}); + +describe('validateBlock', () => { + it('accepts a valid block with no siblings', () => { + expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true }); + }); + + it('rejects equal start and end times', () => { + const result = validateBlock(input({ endMinutes: 6 * 60 }), []); + expect(result).toEqual({ + ok: false, + error: "Start time must be before end time. Overnight blocks aren't supported." + }); + }); + + it('rejects an end time past midnight (overnight blocks disallowed)', () => { + const result = validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), []); + expect(result.ok).toBe(false); + }); + + it('rejects a negative start time', () => { + expect(validateBlock(input({ startMinutes: -1 }), []).ok).toBe(false); + }); + + it('requires at least one proclamation', () => { + expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({ + ok: false, + error: 'Select at least one proclamation.' + }); + }); + + it('enforces the four-block maximum on create', () => { + const siblings = [ + sibling(0, 60, 'a'), + sibling(60, 120, 'b'), + sibling(120, 180, 'c'), + sibling(180, 240, 'd') + ]; + const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, { + isCreate: true + }); + expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' }); + }); + + it('allows adjacent blocks (end exactly equals next start)', () => { + expect( + validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)]) + ).toEqual({ + ok: true + }); + }); + + it('rejects an exact duplicate range and names the conflicting block', () => { + const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]); + expect(result).toEqual({ + ok: false, + error: 'This overlaps with your 6:00 AM–8:00 AM block.' + }); + }); + + it('rejects a partial overlap', () => { + const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [ + sibling(8 * 60, 10 * 60) + ]); + expect(result.ok).toBe(false); + }); + + it('rejects a block that contains another block', () => { + const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [ + sibling(6 * 60, 8 * 60) + ]); + expect(result.ok).toBe(false); + }); + + it('does not apply the maximum when updating (isCreate false)', () => { + const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')]; + expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true); + }); +}); diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts new file mode 100644 index 0000000..9d9e536 --- /dev/null +++ b/src/lib/schedule-utils.ts @@ -0,0 +1,109 @@ +// Isomorphic, dependency-free schedule helpers. Used by the server service +// layer, the v2 serializers and the client-side Block modal (for live +// pre-validation), so it must not import Prisma or any server-only code. + +export const MAX_BLOCKS = 4; +export const MINUTES_PER_DAY = 24 * 60; +export const SNAP_MINUTES = 15; +export const MINUTE_STEP = 5; + +export type BlockSibling = { + id?: string; + startMinutes: number; + endMinutes: number; +}; + +export type BlockInput = { + startMinutes: number; + endMinutes: number; + proclamationIds: string[]; +}; + +export type ValidationResult = { ok: true } | { ok: false; error: string }; + +function normalizeMinutes(minutes: number): number { + return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0; +} + +/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */ +export function formatTime(minutes: number): string { + const normalized = normalizeMinutes(minutes); + const hours24 = Math.floor(normalized / 60); + const mins = normalized % 60; + const period = hours24 < 12 ? 'AM' : 'PM'; + const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12; + return `${hours12}:${String(mins).padStart(2, '0')} ${period}`; +} + +/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */ +export function toHm(minutes: number): string { + const normalized = normalizeMinutes(minutes); + const hours = Math.floor(normalized / 60); + const mins = normalized % 60; + return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; +} + +/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */ +export function snapMinutes(minutes: number): number { + const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES; + return Math.max(0, Math.min(MINUTES_PER_DAY, snapped)); +} + +/** First sibling whose half-open range [start, end) intersects the input. */ +export function findOverlap( + input: { startMinutes: number; endMinutes: number }, + siblings: BlockSibling[] +): BlockSibling | null { + for (const sibling of siblings) { + if (input.startMinutes < sibling.endMinutes && sibling.startMinutes < input.endMinutes) { + return sibling; + } + } + return null; +} + +/** + * Validate a Block against the schedule's other Blocks. `siblings` must exclude + * the Block being edited. `isCreate` enables the 4-block maximum (an update + * never increases the count). + */ +export function validateBlock( + input: BlockInput, + siblings: BlockSibling[], + opts: { isCreate?: boolean } = {} +): ValidationResult { + const { startMinutes, endMinutes, proclamationIds } = input; + + if ( + !Number.isInteger(startMinutes) || + !Number.isInteger(endMinutes) || + startMinutes < 0 || + endMinutes > MINUTES_PER_DAY || + startMinutes >= endMinutes + ) { + return { + ok: false, + error: "Start time must be before end time. Overnight blocks aren't supported." + }; + } + + if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) { + return { ok: false, error: 'Select at least one proclamation.' }; + } + + if (opts.isCreate && siblings.length >= MAX_BLOCKS) { + return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` }; + } + + const overlap = findOverlap(input, siblings); + if (overlap) { + return { + ok: false, + error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime( + overlap.endMinutes + )} block.` + }; + } + + return { ok: true }; +} diff --git a/src/lib/server/schedule.spec.ts b/src/lib/server/schedule.spec.ts new file mode 100644 index 0000000..5a5e641 --- /dev/null +++ b/src/lib/server/schedule.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/db', () => ({ + prisma: { + schedule: { findUnique: vi.fn(), create: vi.fn() }, + block: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } + } +})); + +import { prisma } from '$lib/db'; +import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule'; + +const scheduleFindUnique = prisma.schedule.findUnique as ReturnType; +const scheduleCreate = prisma.schedule.create as ReturnType; +const blockFindMany = prisma.block.findMany as ReturnType; +const blockFindFirst = prisma.block.findFirst as ReturnType; +const blockCreate = prisma.block.create as ReturnType; +const blockUpdate = prisma.block.update as ReturnType; +const blockDelete = prisma.block.delete as ReturnType; + +const validInput = { + startMinutes: 6 * 60, + endMinutes: 8 * 60, + proclamationIds: ['p1'] +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getOrCreateSchedule', () => { + it('returns the existing schedule', async () => { + scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' }); + + const result = await getOrCreateSchedule('u1'); + + expect(result).toEqual({ id: 's1', userId: 'u1' }); + expect(scheduleCreate).not.toHaveBeenCalled(); + }); + + it('creates the singleton schedule when missing', async () => { + scheduleFindUnique.mockResolvedValue(null); + scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' }); + + await getOrCreateSchedule('u1'); + + expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } }); + }); +}); + +describe('listBlocks', () => { + it('scopes to the user and includes proclamations with frequencies', async () => { + blockFindMany.mockResolvedValue([]); + + await listBlocks('u1'); + + expect(blockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { schedule: { userId: 'u1' } }, + orderBy: { startMinutes: 'asc' }, + include: expect.objectContaining({ proclamations: expect.anything() }) + }) + ); + }); +}); + +describe('createBlock', () => { + beforeEach(() => { + scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' }); + }); + + it('creates a block with deduped proclamations in order', async () => { + blockFindMany.mockResolvedValue([]); + blockCreate.mockResolvedValue({ id: 'new-block' }); + + const result = await createBlock('u1', { + ...validInput, + proclamationIds: ['p1', 'p1', 'p2'] + }); + + expect(result).toEqual({ ok: true, id: 'new-block' }); + expect(blockCreate).toHaveBeenCalledWith({ + data: { + scheduleId: 's1', + startMinutes: validInput.startMinutes, + endMinutes: validInput.endMinutes, + proclamations: { + create: [ + { proclamationId: 'p1', sortOrder: 0 }, + { proclamationId: 'p2', sortOrder: 1 } + ] + } + } + }); + }); + + it('rejects a fifth block without creating', async () => { + blockFindMany.mockResolvedValue([ + { id: 'a', startMinutes: 0, endMinutes: 60 }, + { id: 'b', startMinutes: 60, endMinutes: 120 }, + { id: 'c', startMinutes: 120, endMinutes: 180 }, + { id: 'd', startMinutes: 180, endMinutes: 240 } + ]); + + const result = await createBlock('u1', validInput); + + expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' }); + expect(blockCreate).not.toHaveBeenCalled(); + }); + + it('rejects an overlapping block with a specific message', async () => { + blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]); + + const result = await createBlock('u1', validInput); + + expect(result).toEqual({ + ok: false, + error: 'This overlaps with your 7:00 AM–9:00 AM block.' + }); + expect(blockCreate).not.toHaveBeenCalled(); + }); +}); + +describe('updateBlock', () => { + it('returns not found when the block does not belong to the user', async () => { + blockFindFirst.mockResolvedValue(null); + + const result = await updateBlock('u1', 'missing', validInput); + + expect(result).toEqual({ ok: false, error: 'Block not found.' }); + expect(blockUpdate).not.toHaveBeenCalled(); + }); + + it('replaces proclamations and excludes itself from overlap checks', async () => { + blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' }); + blockFindMany.mockResolvedValue([]); + blockUpdate.mockResolvedValue({ id: 'b1' }); + + const result = await updateBlock('u1', 'b1', validInput); + + expect(result).toEqual({ ok: true, id: 'b1' }); + expect(blockFindMany).toHaveBeenCalledWith({ + where: { scheduleId: 's1', id: { not: 'b1' } }, + select: { id: true, startMinutes: true, endMinutes: true } + }); + expect(blockUpdate).toHaveBeenCalledWith({ + where: { id: 'b1' }, + data: { + startMinutes: validInput.startMinutes, + endMinutes: validInput.endMinutes, + proclamations: { + deleteMany: {}, + create: [{ proclamationId: 'p1', sortOrder: 0 }] + } + } + }); + }); +}); + +describe('deleteBlock', () => { + it('returns not found when the block does not belong to the user', async () => { + blockFindFirst.mockResolvedValue(null); + + const result = await deleteBlock('u1', 'missing'); + + expect(result).toEqual({ ok: false, error: 'Block not found.' }); + expect(blockDelete).not.toHaveBeenCalled(); + }); + + it('deletes an owned block', async () => { + blockFindFirst.mockResolvedValue({ id: 'b1' }); + blockDelete.mockResolvedValue({ id: 'b1' }); + + const result = await deleteBlock('u1', 'b1'); + + expect(result).toEqual({ ok: true, id: 'b1' }); + expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } }); + }); +}); diff --git a/src/lib/server/schedule.ts b/src/lib/server/schedule.ts new file mode 100644 index 0000000..3df0e14 --- /dev/null +++ b/src/lib/server/schedule.ts @@ -0,0 +1,129 @@ +// Schedule service layer. Owns the database orchestration and enforcement of +// the Block business rules (at most 4 per user, at least one Proclamation, +// no overnight ranges, no overlaps). Pure validation lives in +// `$lib/schedule-utils` so the client modal can reuse it. + +import { prisma } from '$lib/db'; +import { validateBlock, type BlockInput } from '$lib/schedule-utils'; + +export { + MAX_BLOCKS, + MINUTES_PER_DAY, + SNAP_MINUTES, + MINUTE_STEP, + formatTime, + toHm, + snapMinutes, + findOverlap, + validateBlock +} from '$lib/schedule-utils'; +export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils'; + +export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string }; + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))]; +} + +/** Ensure the user has a Schedule row, creating the singleton on first use. */ +export async function getOrCreateSchedule(userId: string) { + const existing = await prisma.schedule.findUnique({ where: { userId } }); + if (existing) return existing; + return prisma.schedule.create({ data: { userId } }); +} + +/** + * The user's Blocks with their selected Proclamations (and frequencies for + * canonical-id mapping), ordered by start time. + */ +export async function listBlocks(userId: string) { + return prisma.block.findMany({ + where: { schedule: { userId } }, + orderBy: { startMinutes: 'asc' }, + include: { + proclamations: { + orderBy: { sortOrder: 'asc' }, + include: { + proclamation: { include: { solfeggioFrequency: true } } + } + } + } + }); +} + +export async function createBlock(userId: string, input: BlockInput): Promise { + const schedule = await getOrCreateSchedule(userId); + const siblings = await prisma.block.findMany({ + where: { scheduleId: schedule.id }, + select: { id: true, startMinutes: true, endMinutes: true } + }); + + const validation = validateBlock(input, siblings, { isCreate: true }); + if (!validation.ok) return validation; + + const proclamationIds = uniqueIds(input.proclamationIds); + const block = await prisma.block.create({ + data: { + scheduleId: schedule.id, + startMinutes: input.startMinutes, + endMinutes: input.endMinutes, + proclamations: { + create: proclamationIds.map((proclamationId, index) => ({ + proclamationId, + sortOrder: index + })) + } + } + }); + + return { ok: true, id: block.id }; +} + +export async function updateBlock( + userId: string, + blockId: string, + input: BlockInput +): Promise { + const block = await prisma.block.findFirst({ + where: { id: blockId, schedule: { userId } }, + select: { id: true, scheduleId: true } + }); + if (!block) return { ok: false, error: 'Block not found.' }; + + const siblings = await prisma.block.findMany({ + where: { scheduleId: block.scheduleId, id: { not: blockId } }, + select: { id: true, startMinutes: true, endMinutes: true } + }); + + const validation = validateBlock(input, siblings); + if (!validation.ok) return validation; + + const proclamationIds = uniqueIds(input.proclamationIds); + await prisma.block.update({ + where: { id: blockId }, + data: { + startMinutes: input.startMinutes, + endMinutes: input.endMinutes, + proclamations: { + deleteMany: {}, + create: proclamationIds.map((proclamationId, index) => ({ + proclamationId, + sortOrder: index + })) + } + } + }); + + return { ok: true, id: blockId }; +} + +export async function deleteBlock(userId: string, blockId: string): Promise { + const block = await prisma.block.findFirst({ + where: { id: blockId, schedule: { userId } }, + select: { id: true } + }); + if (!block) return { ok: false, error: 'Block not found.' }; + + await prisma.block.delete({ where: { id: blockId } }); + return { ok: true, id: blockId }; +}