Portal: per-license schedule service, shared validator, and key-only GET

Implements the shared validateSchedule validator (daily + weekly, midnight
spillover, Sunday->Monday wrap, adjacency, empty-block rejection), a
per-License-Key schedule service (get/set type, CRUD blocks, weekly
prefill with confirmation), the content-hash version/ETag serializer, the
new GET /api/v2/licenses/[licenseKey]/schedule (200/304/404, type null),
and additive evolution of the legacy GET /api/v2/schedule. Adds validator,
serializer (against docs/fixtures), endpoint ETag/304, and service tests.
This commit is contained in:
NeilClack
2026-09-29 10:23:10 -05:00
parent 138bf03ea9
commit 3bb8e0189b
12 changed files with 1285 additions and 231 deletions
+181
View File
@@ -23,6 +23,187 @@ export type BlockInput = {
export type ValidationResult = { ok: true } | { ok: false; error: string };
// ─── Schedule-wide validation (daily + weekly, midnight spillover) ─────
export type ScheduleType = 'daily' | 'weekly';
export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
export type WeekDay = (typeof WEEK_DAYS)[number];
/** A Block as seen by the validator. `day` is null for daily schedules. */
export type ValidationBlock = {
id?: string;
day?: string | null;
startMinutes: number;
endMinutes: number;
proclamationIds?: string[];
};
/** Block length in minutes, counting a wrap past midnight as the next day. */
function blockLinearLength(block: { startMinutes: number; endMinutes: number }): number {
return block.endMinutes > block.startMinutes
? block.endMinutes - block.startMinutes
: block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/** Linear half-open interval `[start, end)` relative to `dayIndex`. */
function dayInterval(
block: { startMinutes: number; endMinutes: number },
dayIndex: number
): [number, number] {
const base = dayIndex * MINUTES_PER_DAY;
const start = base + block.startMinutes;
return [start, start + blockLinearLength(block)];
}
function isCrossing(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes < block.startMinutes;
}
function blocksForDay(blocks: ValidationBlock[], dayIndex: number): ValidationBlock[] {
const day = WEEK_DAYS[dayIndex];
return blocks.filter((b) => (b.day ?? null) === day);
}
/**
* First overlapping pair of Blocks in a schedule, or null. Implements the
* contract's overlap rules: touching boundaries are fine; a midnight-crossing
* Block spills into the next day (weekly Sunday→Monday) and must not overlap the
* next day's early Blocks.
*/
export function findScheduleOverlap(
type: ScheduleType,
blocks: ValidationBlock[]
): { a: ValidationBlock; b: ValidationBlock } | null {
if (type === 'daily') {
// Daily is a single day list that wraps onto itself: a crossing Block
// spills into the next day's copy of the same list. First check the
// same-day intervals, then every crossing Block against the next day.
const intervals = blocks.map((b) => ({ block: b, interval: dayInterval(b, 0) }));
const sameDay = firstPairOverlap(intervals);
if (sameDay) return sameDay;
for (const crossing of blocks.filter(isCrossing)) {
// The crossing Block's post-midnight tail, expressed in this day's
// 0-based frame (it spans [0, endMinutes)).
const spill: IntervalEntry = {
block: crossing,
interval: [0, crossing.endMinutes]
};
for (const { block, interval } of intervals) {
if (block === crossing) continue;
if (segmentsIntersect(spill.interval, interval)) {
return { a: crossing, b: block };
}
}
}
return null;
}
for (let d = 0; d < WEEK_DAYS.length; d++) {
const dayBlocks = blocksForDay(blocks, d);
const intervals = dayBlocks.map((b) => ({ block: b, interval: dayInterval(b, d) }));
// Same-day overlaps (a crossing Block's late segment vs the same day).
const pair = firstPairOverlap(intervals);
if (pair) return pair;
// Spillover from the previous day's crossing Blocks into this day's early
// Blocks. All spills start at midnight, so their union is [0, max(end)).
const prevIndex = (d - 1 + WEEK_DAYS.length) % WEEK_DAYS.length;
const prevCrossing = blocksForDay(blocks, prevIndex).filter(isCrossing);
for (const prev of prevCrossing) {
const spill: [number, number] = [d * MINUTES_PER_DAY, d * MINUTES_PER_DAY + prev.endMinutes];
for (const { block, interval } of intervals) {
if (segmentsIntersect(spill, interval)) {
return { a: prev, b: block };
}
}
}
}
return null;
}
type IntervalEntry = { block: ValidationBlock; interval: [number, number] };
function firstPairOverlap(
entries: IntervalEntry[]
): { a: ValidationBlock; b: ValidationBlock } | null {
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
if (segmentsIntersect(entries[i].interval, entries[j].interval)) {
return { a: entries[i].block, b: entries[j].block };
}
}
}
return null;
}
/** Validate one Block's shape (time range + at least one Proclamation). */
export function validateBlockShape(block: ValidationBlock): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = block;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (block.day != null && !WEEK_DAYS.includes(block.day as WeekDay)) {
return { ok: false, error: 'Pick a valid day of the week.' };
}
if (proclamationIds !== undefined) {
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
}
return { ok: true };
}
/**
* Validate a whole schedule. The server is authoritative; the client calls the
* same function for instant feedback.
*/
export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]): ValidationResult {
for (const block of blocks) {
const shape = validateBlockShape(block);
if (!shape.ok) return shape;
}
const overlap = findScheduleOverlap(type, blocks);
if (overlap) {
return { ok: false, error: overlapError(overlap.a, overlap.b) };
}
return { ok: true };
}
/**
* Validate a candidate Block against the schedule's other Blocks (which must
* exclude the candidate when editing).
*/
export function validateBlockAgainstSchedule(
type: ScheduleType,
candidate: ValidationBlock,
siblings: ValidationBlock[]
): ValidationResult {
const shape = validateBlockShape(candidate);
if (!shape.ok) return shape;
const overlap = findScheduleOverlap(type, [...siblings, candidate]);
if (overlap) {
return { ok: false, error: overlapError(overlap.a, overlap.b) };
}
return { ok: true };
}
function overlapError(a: ValidationBlock, b: ValidationBlock): string {
return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`;
}
function normalizeMinutes(minutes: number): number {
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
}