Files
insights/products/alerts/frontend/logic/scheduleRestrictionValidation.ts
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

210 lines
6.9 KiB
TypeScript

import type { BlockedWindow, ScheduleRestriction } from '../types'
export const MAX_BLOCKED_WINDOWS = 5
export const MIN_BLOCKED_WINDOW_MINUTES = 30
export const MINUTES_PER_DAY = 1440
export type QuietHoursIssue = { kind: 'row'; index: number; message: string } | { kind: 'form'; message: string }
type ParsedSegment = { start: number; end: number; overnight: boolean }
/** Strict HH:MM only (one colon, no seconds), 24-hour wall clock. */
export function parseHHMMStrict(s: string): number | null {
const str = s.trim()
if (str.split(':').length !== 2) {
return null
}
const [hs, ms] = str.split(':')
if (!/^\d{1,2}$/.test(hs) || !/^\d{1,2}$/.test(ms)) {
return null
}
const h = parseInt(hs, 10)
const m = parseInt(ms, 10)
if (Number.isNaN(h) || Number.isNaN(m) || h < 0 || h > 23 || m < 0 || m > 59) {
return null
}
return h * 60 + m
}
function mergeIntervalsSorted(intervals: [number, number][]): [number, number][] {
if (intervals.length === 0) {
return []
}
const sorted = [...intervals].sort((a, b) => a[0] - b[0] || a[1] - b[1])
const merged: [number, number][] = []
for (const [a, b] of sorted) {
if (b <= a) {
continue
}
if (merged.length === 0 || merged[merged.length - 1][1] < a) {
merged.push([a, b])
} else {
const [pa, pb] = merged[merged.length - 1]
merged[merged.length - 1] = [pa, Math.max(pb, b)]
}
}
return merged
}
function expandForCoverage(parsed: ParsedSegment[]): [number, number][] | null {
const out: [number, number][] = []
for (const { start, end, overnight } of parsed) {
if (!overnight) {
if (start < end) {
out.push([start, end])
} else {
return null
}
} else if (end === 0) {
out.push([start, MINUTES_PER_DAY])
} else {
out.push([start, MINUTES_PER_DAY])
out.push([0, end])
}
}
return out
}
function mergedCoversFullDay(merged: [number, number][]): boolean {
return merged.length > 0 && merged[0][0] === 0 && merged[0][1] >= MINUTES_PER_DAY
}
/** Half-open complement of blocked intervals within [0, dayLen). Assumes `blocked` merged and sorted. */
export function complementMinuteIntervals(
blocked: [number, number][],
dayLen: number = MINUTES_PER_DAY
): [number, number][] {
if (blocked.length === 0) {
return [[0, dayLen]]
}
const allowed: [number, number][] = []
let cursor = 0
const sorted = [...blocked].sort((a, b) => a[0] - b[0] || a[1] - b[1])
for (const [a, b] of sorted) {
if (b <= a) {
continue
}
if (cursor < a) {
allowed.push([cursor, a])
}
cursor = Math.max(cursor, b)
}
if (cursor < dayLen) {
allowed.push([cursor, dayLen])
}
return allowed
}
type MergeBlockedResult = { merged: [number, number][] } | { issue: QuietHoursIssue }
/** Shared parse + merge (same rules as form validation). */
export function mergeBlockedWindowsOrIssue(windows: BlockedWindow[]): MergeBlockedResult {
if (windows.length > MAX_BLOCKED_WINDOWS) {
return {
issue: {
kind: 'form',
message: `At most ${MAX_BLOCKED_WINDOWS} time windows for quiet hours per alert`,
},
}
}
const parsed: ParsedSegment[] = []
for (let i = 0; i < windows.length; i++) {
const { start, end } = windows[i]
const sm = parseHHMMStrict(start)
if (sm === null) {
return { issue: { kind: 'row', index: i, message: 'Use a valid start time (HH:MM, 24-hour, no seconds).' } }
}
const em = parseHHMMStrict(end)
if (em === null) {
return { issue: { kind: 'row', index: i, message: 'Use a valid end time (HH:MM, 24-hour, no seconds).' } }
}
if (sm === em) {
return { issue: { kind: 'row', index: i, message: 'Start and end must differ.' } }
}
const overnight = sm > em
const span = blockedWindowSpanMinutes(sm, em, overnight)
if (span < MIN_BLOCKED_WINDOW_MINUTES) {
return {
issue: {
kind: 'row',
index: i,
message: `Each quiet hours window must span at least ${MIN_BLOCKED_WINDOW_MINUTES} minutes.`,
},
}
}
parsed.push({ start: sm, end: em, overnight })
}
const expanded = expandForCoverage(parsed)
if (!expanded) {
return { issue: { kind: 'form', message: 'Invalid time windows for quiet hours.' } }
}
const merged = mergeIntervalsSorted(expanded)
if (mergedCoversFullDay(merged)) {
return {
issue: {
kind: 'form',
message: 'Leave at least one time in the day when this alert can run.',
},
}
}
return { merged }
}
/** Single parse/merge for timeline UI: blocked + allowed minutes in [0, MINUTES_PER_DAY). */
export function blockedAndAllowedMinuteIntervalsForQuietHours(
windows: BlockedWindow[]
): { blocked: [number, number][]; allowed: [number, number][] } | null {
if (windows.length === 0) {
return null
}
const result = mergeBlockedWindowsOrIssue(windows)
if ('issue' in result) {
return null
}
const blocked = result.merged
return { blocked, allowed: complementMinuteIntervals(blocked) }
}
/** Merged quiet (blocked) intervals for one local day, or `null` if windows are invalid for save. */
export function mergedBlockedIntervalsForQuietHours(windows: BlockedWindow[]): [number, number][] | null {
return blockedAndAllowedMinuteIntervalsForQuietHours(windows)?.blocked ?? null
}
/** Minutes of the local day when the alert may run (complement of quiet hours), or `null` if invalid. */
export function allowedLocalMinuteIntervalsForQuietHours(windows: BlockedWindow[]): [number, number][] | null {
return blockedAndAllowedMinuteIntervalsForQuietHours(windows)?.allowed ?? null
}
function blockedWindowSpanMinutes(start: number, end: number, overnight: boolean): number {
if (!overnight) {
return end - start
}
if (end === 0) {
return MINUTES_PER_DAY - start
}
return MINUTES_PER_DAY - start + end
}
export function findQuietHoursIssues(windows: BlockedWindow[]): QuietHoursIssue | null {
if (windows.length === 0) {
return null
}
const result = mergeBlockedWindowsOrIssue(windows)
return 'issue' in result ? result.issue : null
}
/** First error message for kea-forms `errors`, or undefined if valid / feature off. */
export function quietHoursFormError(scheduleRestriction: ScheduleRestriction | null | undefined): string | undefined {
const windows = scheduleRestriction?.blocked_windows
if (!windows?.length) {
return undefined
}
const issue = findQuietHoursIssues(windows)
return issue?.message
}