Add schedule validation and service layer

This commit is contained in:
NeilClack
2026-09-11 18:16:00 -05:00
parent e6b492a335
commit 4922e941ae
4 changed files with 567 additions and 0 deletions
+144
View File
@@ -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> = {}): 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);
});
});
+109
View File
@@ -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 };
}
+185
View File
@@ -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<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
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' } });
});
});
+129
View File
@@ -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<BlockMutationResult> {
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<BlockMutationResult> {
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<BlockMutationResult> {
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 };
}