From 750a9286c968395c81fcd1e7a55a296fc54c4895 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 11 Sep 2026 18:24:48 -0500 Subject: [PATCH] Support overnight blocks in schedule validation --- src/lib/schedule-utils.spec.ts | 101 +++++++++++++++++++++++++++++++-- src/lib/schedule-utils.ts | 68 +++++++++++++++++++--- 2 files changed, 155 insertions(+), 14 deletions(-) diff --git a/src/lib/schedule-utils.spec.ts b/src/lib/schedule-utils.spec.ts index 6d0fb44..afa03bd 100644 --- a/src/lib/schedule-utils.spec.ts +++ b/src/lib/schedule-utils.spec.ts @@ -1,7 +1,10 @@ import { describe, it, expect } from 'vitest'; import { + blockDuration, + blockSegments, findOverlap, formatTime, + isOvernight, snapMinutes, toHm, validateBlock, @@ -52,6 +55,49 @@ describe('snapMinutes', () => { }); }); +describe('isOvernight', () => { + it('is true when the end clock time is before the start', () => { + expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true); + expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true); + }); + + it('is false for same-day ranges and a full-day range', () => { + expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false); + expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false); + }); +}); + +describe('blockDuration', () => { + it('counts a same-day range directly', () => { + expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120); + }); + + it('counts an overnight range as wrapping to the next day', () => { + expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720); + }); + + it('treats an end of midnight as wrapping from the start', () => { + expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120); + }); +}); + +describe('blockSegments', () => { + it('returns a single segment for a same-day range', () => { + expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]); + }); + + it('splits an overnight range at midnight', () => { + expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([ + [21 * 60, 1440], + [0, 9 * 60] + ]); + }); + + it('returns no segments for a zero-length range', () => { + expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]); + }); +}); + describe('findOverlap', () => { it('returns null for adjacent ranges', () => { expect( @@ -63,6 +109,28 @@ describe('findOverlap', () => { const other = sibling(7 * 60, 9 * 60); expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other); }); + + it('detects an overnight block overlapping an early-morning block', () => { + const other = sibling(8 * 60, 10 * 60); + expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other); + }); + + it('detects an overnight block overlapping a late-evening block', () => { + const other = sibling(20 * 60, 22 * 60); + expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other); + }); + + it('allows an overnight block and its exact complement (adjacent at both ends)', () => { + expect( + findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)]) + ).toBeNull(); + }); + + it('allows an overnight block and a midday block it does not touch', () => { + expect( + findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)]) + ).toBeNull(); + }); }); describe('validateBlock', () => { @@ -74,17 +142,40 @@ describe('validateBlock', () => { 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." + error: "Start and end time can't be the same." }); }); - 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 time outside the 24-hour range', () => { + expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({ + ok: false, + error: 'Enter a valid start and end time.' + }); }); it('rejects a negative start time', () => { - expect(validateBlock(input({ startMinutes: -1 }), []).ok).toBe(false); + expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({ + ok: false, + error: 'Enter a valid start and end time.' + }); + }); + + it('accepts an overnight block', () => { + expect( + validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], { + isCreate: true + }) + ).toEqual({ ok: true }); + }); + + it('rejects an overnight block that overlaps another and names it', () => { + const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [ + sibling(8 * 60, 10 * 60) + ]); + expect(result).toEqual({ + ok: false, + error: 'This overlaps with your 8:00 AM–10:00 AM block.' + }); }); it('requires at least one proclamation', () => { diff --git a/src/lib/schedule-utils.ts b/src/lib/schedule-utils.ts index 9d9e536..be6f9b8 100644 --- a/src/lib/schedule-utils.ts +++ b/src/lib/schedule-utils.ts @@ -49,14 +49,60 @@ export function snapMinutes(minutes: number): number { return Math.max(0, Math.min(MINUTES_PER_DAY, snapped)); } -/** First sibling whose half-open range [start, end) intersects the input. */ +/** A Block whose end clock time is at or before its start wraps past midnight. */ +export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean { + return block.endMinutes <= block.startMinutes; +} + +/** Block length in minutes, counting a wrap past midnight as the next day. */ +export function blockDuration(block: { startMinutes: number; endMinutes: number }): number { + if (block.endMinutes > block.startMinutes) { + return block.endMinutes - block.startMinutes; + } + return block.endMinutes - block.startMinutes + MINUTES_PER_DAY; +} + +/** + * Split a Block into the linear half-open `[start, end)` segments it occupies + * within the 24h day. An overnight block produces two segments (start→midnight + * and midnight→end); a zero-length block produces none. + */ +export function blockSegments(block: { + startMinutes: number; + endMinutes: number; +}): [number, number][] { + if (block.startMinutes < block.endMinutes) { + return [[block.startMinutes, block.endMinutes]]; + } + if (block.startMinutes > block.endMinutes) { + return [ + [block.startMinutes, MINUTES_PER_DAY], + [0, block.endMinutes] + ]; + } + return []; +} + +function segmentsIntersect(a: [number, number], b: [number, number]): boolean { + return a[0] < b[1] && b[0] < a[1]; +} + +/** + * First sibling whose occupied time intersects the input. Works for overnight + * (midnight-crossing) blocks by comparing their linear segments; ranges that + * only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap. + */ export function findOverlap( input: { startMinutes: number; endMinutes: number }, siblings: BlockSibling[] ): BlockSibling | null { + const inputSegments = blockSegments(input); for (const sibling of siblings) { - if (input.startMinutes < sibling.endMinutes && sibling.startMinutes < input.endMinutes) { - return sibling; + const siblingSegments = blockSegments(sibling); + for (const segment of inputSegments) { + for (const other of siblingSegments) { + if (segmentsIntersect(segment, other)) return sibling; + } } } return null; @@ -78,13 +124,17 @@ export function validateBlock( !Number.isInteger(startMinutes) || !Number.isInteger(endMinutes) || startMinutes < 0 || - endMinutes > MINUTES_PER_DAY || - startMinutes >= endMinutes + startMinutes > MINUTES_PER_DAY || + endMinutes < 0 || + endMinutes > MINUTES_PER_DAY ) { - return { - ok: false, - error: "Start time must be before end time. Overnight blocks aren't supported." - }; + return { ok: false, error: 'Enter a valid start and end time.' }; + } + + // Equal clock times would be a zero- or full-length block; both are + // ambiguous, so reject them. Overnight ranges (end before start) are valid. + if (startMinutes === endMinutes) { + return { ok: false, error: "Start and end time can't be the same." }; } if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {