Support overnight blocks in schedule validation
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
blockDuration,
|
||||||
|
blockSegments,
|
||||||
findOverlap,
|
findOverlap,
|
||||||
formatTime,
|
formatTime,
|
||||||
|
isOvernight,
|
||||||
snapMinutes,
|
snapMinutes,
|
||||||
toHm,
|
toHm,
|
||||||
validateBlock,
|
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', () => {
|
describe('findOverlap', () => {
|
||||||
it('returns null for adjacent ranges', () => {
|
it('returns null for adjacent ranges', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -63,6 +109,28 @@ describe('findOverlap', () => {
|
|||||||
const other = sibling(7 * 60, 9 * 60);
|
const other = sibling(7 * 60, 9 * 60);
|
||||||
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
|
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', () => {
|
describe('validateBlock', () => {
|
||||||
@@ -74,17 +142,40 @@ describe('validateBlock', () => {
|
|||||||
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
|
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: false,
|
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)', () => {
|
it('rejects a time outside the 24-hour range', () => {
|
||||||
const result = validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), []);
|
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
|
||||||
expect(result.ok).toBe(false);
|
ok: false,
|
||||||
|
error: 'Enter a valid start and end time.'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a negative start 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', () => {
|
it('requires at least one proclamation', () => {
|
||||||
|
|||||||
@@ -49,14 +49,60 @@ export function snapMinutes(minutes: number): number {
|
|||||||
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
|
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(
|
export function findOverlap(
|
||||||
input: { startMinutes: number; endMinutes: number },
|
input: { startMinutes: number; endMinutes: number },
|
||||||
siblings: BlockSibling[]
|
siblings: BlockSibling[]
|
||||||
): BlockSibling | null {
|
): BlockSibling | null {
|
||||||
|
const inputSegments = blockSegments(input);
|
||||||
for (const sibling of siblings) {
|
for (const sibling of siblings) {
|
||||||
if (input.startMinutes < sibling.endMinutes && sibling.startMinutes < input.endMinutes) {
|
const siblingSegments = blockSegments(sibling);
|
||||||
return sibling;
|
for (const segment of inputSegments) {
|
||||||
|
for (const other of siblingSegments) {
|
||||||
|
if (segmentsIntersect(segment, other)) return sibling;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -78,13 +124,17 @@ export function validateBlock(
|
|||||||
!Number.isInteger(startMinutes) ||
|
!Number.isInteger(startMinutes) ||
|
||||||
!Number.isInteger(endMinutes) ||
|
!Number.isInteger(endMinutes) ||
|
||||||
startMinutes < 0 ||
|
startMinutes < 0 ||
|
||||||
endMinutes > MINUTES_PER_DAY ||
|
startMinutes > MINUTES_PER_DAY ||
|
||||||
startMinutes >= endMinutes
|
endMinutes < 0 ||
|
||||||
|
endMinutes > MINUTES_PER_DAY
|
||||||
) {
|
) {
|
||||||
return {
|
return { ok: false, error: 'Enter a valid start and end time.' };
|
||||||
ok: false,
|
}
|
||||||
error: "Start time must be before end time. Overnight blocks aren't supported."
|
|
||||||
};
|
// 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) {
|
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user