timeconditions: office hours, holidays, override codes, weekly grid
/api/time-conditions (company time zone by default, ranges validated,
unique override digit, never pointing at itself) with a live status
("open/closed/holiday now", same logic as the dialplan, shared), and
POST /:id/override, which writes PostgreSQL then AstDB SP/TCOVR.
/api/holidays CRUD plus import-us-federal (observed dates, computed in
code, skips days already listed). services/astdb.ts resyncs AstDB on
every AMI connect (PostgreSQL wins); services/toggles.ts applies
SpToggle TCOVR from *31N/*32N/*33N.
Generator: sp-tc-{id} per 03 §6.10 (override, holidays within the next
year, ranges rendered a minute short because GotoIfTime's end is
inclusive), sp-tc-override with a no-service fallback, admin-only
*3[123]X in sp-featurecodes, destApp time_condition. The date changing
in the company time zone triggers a forced apply so holidays roll over.
Golden cases holidays and ivr-business-hours.
Office hours page: click-and-drag half-hour grid plus an exact range
list, destinations, holiday destination, override key, status chip with
Force open / Force closed / Automatic; Holidays tab with the US federal
import. e2e m6-t4 found two bugs (pointer capture release throwing,
drawer overflow) and checks routing, AstDB, *321/*331 from an admin
phone, refusal for others, and the import reaching the dialplan.
Task: M6-T4
This commit is contained in:
1 parent
15624f7b77
commit
108547046c
57 files changed
+3221
-29
No files matched your search
@@ -14,3 +14,4 @@ export * from './voicemail.ts';
|
||||
export * from './sounds.ts';
|
||||
export * from './ringgroups.ts';
|
||||
export * from './ivrs.ts';
|
||||
export * from './timeconditions.ts';
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isHoliday, rangesToSlots, slotsToRanges, timeConditionStatus } from './timeconditions.ts';
|
||||
|
||||
const tc = {
|
||||
timezone: 'America/Chicago',
|
||||
openRanges: [
|
||||
{ days: ['mon', 'tue', 'wed', 'thu', 'fri'] as const, start: '08:00', end: '17:00' },
|
||||
].map((r) => ({ ...r, days: [...r.days] })),
|
||||
useHolidays: true,
|
||||
overrideState: null,
|
||||
};
|
||||
// 2026-10-19 is a Monday; Chicago is UTC-5 (CDT) then.
|
||||
const at = (localHHMM: string, date = '2026-10-19') => new Date(`${date}T${localHHMM}:00-05:00`);
|
||||
|
||||
describe('timeConditionStatus', () => {
|
||||
it('open inside the hours, closed at the end minute and on weekends', () => {
|
||||
expect(timeConditionStatus(tc, [], at('08:00')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('16:59')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('17:00')).now).toBe('closed');
|
||||
expect(timeConditionStatus(tc, [], at('07:59')).now).toBe('closed');
|
||||
expect(timeConditionStatus(tc, [], at('12:00', '2026-10-18')).now).toBe('closed'); // Sunday
|
||||
});
|
||||
|
||||
it('uses the condition time zone, not the server one', () => {
|
||||
const ny = { ...tc, timezone: 'America/New_York' }; // 08:30 Chicago = 09:30 New York
|
||||
expect(timeConditionStatus(ny, [], at('07:30')).now).toBe('open');
|
||||
expect(timeConditionStatus(tc, [], at('07:30')).now).toBe('closed');
|
||||
});
|
||||
|
||||
it('holidays beat hours; overrides beat everything', () => {
|
||||
const h = [{ startDate: '2026-10-19', endDate: '2026-10-19', recurringYearly: false }];
|
||||
expect(timeConditionStatus(tc, h, at('10:00'))).toEqual({ now: 'holiday', reason: 'holiday' });
|
||||
expect(timeConditionStatus({ ...tc, useHolidays: false }, h, at('10:00')).now).toBe('open');
|
||||
expect(timeConditionStatus({ ...tc, overrideState: 'closed' }, [], at('10:00'))).toEqual({
|
||||
now: 'closed',
|
||||
reason: 'override',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHoliday', () => {
|
||||
it('one-off ranges and yearly ones, including over New Year', () => {
|
||||
const range = { startDate: '2026-12-24', endDate: '2026-12-26', recurringYearly: false };
|
||||
expect(isHoliday(range, '2026-12-25')).toBe(true);
|
||||
expect(isHoliday(range, '2027-12-25')).toBe(false);
|
||||
const xmas = { startDate: '2020-12-25', endDate: '2020-12-25', recurringYearly: true };
|
||||
expect(isHoliday(xmas, '2031-12-25')).toBe(true);
|
||||
const nye = { startDate: '2020-12-31', endDate: '2021-01-01', recurringYearly: true };
|
||||
expect(isHoliday(nye, '2030-01-01')).toBe(true);
|
||||
expect(isHoliday(nye, '2030-01-02')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekly grid', () => {
|
||||
it('round-trips ranges, grouping days with the same hours', () => {
|
||||
const ranges = [
|
||||
{
|
||||
days: ['mon' as const, 'tue' as const, 'wed' as const, 'thu' as const, 'fri' as const],
|
||||
start: '08:00',
|
||||
end: '12:00',
|
||||
},
|
||||
{
|
||||
days: ['mon' as const, 'tue' as const, 'wed' as const, 'thu' as const, 'fri' as const],
|
||||
start: '13:00',
|
||||
end: '17:30',
|
||||
},
|
||||
{ days: ['sat' as const], start: '09:00', end: '24:00' },
|
||||
];
|
||||
const slots = rangesToSlots(ranges);
|
||||
expect(slots.mon[16]).toBe(true); // 08:00
|
||||
expect(slots.mon[24]).toBe(false); // 12:00 (lunch)
|
||||
expect(slots.sat[47]).toBe(true); // 23:30
|
||||
expect(slotsToRanges(slots)).toEqual(ranges);
|
||||
});
|
||||
|
||||
it('a day painted differently splits from the group', () => {
|
||||
const slots = rangesToSlots([{ days: ['mon', 'tue'], start: '08:00', end: '17:00' }]);
|
||||
slots.tue[34] = true; // 17:00-17:30 added on Tuesday only
|
||||
expect(slotsToRanges(slots)).toEqual([
|
||||
{ days: ['mon'], start: '08:00', end: '17:00' },
|
||||
{ days: ['tue'], start: '08:00', end: '17:30' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Office hours / time conditions and holidays (02 time_conditions, holidays; 03 §6.10).
|
||||
import { z } from 'zod';
|
||||
import { DestinationSchema, type Destination } from './destination.ts';
|
||||
|
||||
export const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||
export type Day = (typeof DAYS)[number];
|
||||
|
||||
/** "08:00".."24:00"; the end is exclusive (closed at 17:00 means open until 16:59). */
|
||||
const Time = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$|^24:00$/, 'Like 08:30');
|
||||
|
||||
export const OpenRangeSchema = z
|
||||
.object({
|
||||
days: z.array(z.enum(DAYS)).min(1, 'Pick at least one day'),
|
||||
start: Time,
|
||||
end: Time,
|
||||
})
|
||||
// Ranges can't cross midnight: add two (03 §6.10).
|
||||
.refine((r) => r.start < r.end, 'The end must be after the start');
|
||||
export type OpenRange = z.infer<typeof OpenRangeSchema>;
|
||||
|
||||
export const OVERRIDE_STATES = ['open', 'closed', 'auto'] as const;
|
||||
export type OverrideState = (typeof OVERRIDE_STATES)[number];
|
||||
|
||||
const TimeConditionFields = z.object({
|
||||
name: z.string().trim().min(1, 'Required').max(40),
|
||||
timezone: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_+/-]*$/, 'A time zone like America/Chicago')
|
||||
.refine((tz) => {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Unknown time zone'),
|
||||
openRanges: z.array(OpenRangeSchema).max(30),
|
||||
useHolidays: z.boolean(),
|
||||
/** N in the feature codes *31N (force open), *32N (force closed), *33N (automatic). */
|
||||
overrideDigit: z.number().int().min(1).max(9).nullable(),
|
||||
openDestination: DestinationSchema,
|
||||
closedDestination: DestinationSchema,
|
||||
/** null = the closed destination. */
|
||||
holidayDestination: DestinationSchema.nullable(),
|
||||
});
|
||||
|
||||
export const TimeConditionCreateSchema = TimeConditionFields.partial().required({
|
||||
name: true,
|
||||
openDestination: true,
|
||||
closedDestination: true,
|
||||
});
|
||||
export const TimeConditionUpdateSchema = TimeConditionFields.partial();
|
||||
export type TimeConditionCreate = z.input<typeof TimeConditionCreateSchema>;
|
||||
export type TimeConditionUpdate = z.input<typeof TimeConditionUpdateSchema>;
|
||||
export const OverrideBodySchema = z.object({ state: z.enum(OVERRIDE_STATES) });
|
||||
|
||||
export type TimeConditionStatus = {
|
||||
/** What callers get right now. */
|
||||
now: 'open' | 'closed' | 'holiday';
|
||||
/** Why: forced by someone, a holiday, or the weekly hours. */
|
||||
reason: 'override' | 'holiday' | 'hours';
|
||||
};
|
||||
|
||||
export type TimeConditionDto = z.output<typeof TimeConditionFields> & {
|
||||
id: string;
|
||||
openDestination: Destination;
|
||||
closedDestination: Destination;
|
||||
holidayDestination: Destination | null;
|
||||
/** null = automatic. */
|
||||
overrideState: 'open' | 'closed' | null;
|
||||
status: TimeConditionStatus;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const Day = z.iso.date();
|
||||
export const HolidaySchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, 'Required').max(60),
|
||||
startDate: Day,
|
||||
endDate: Day,
|
||||
/** Same month and day every year (e.g. Christmas). */
|
||||
recurringYearly: z.boolean().default(false),
|
||||
})
|
||||
.refine((h) => h.startDate <= h.endDate, {
|
||||
message: 'The end is before the start',
|
||||
path: ['endDate'],
|
||||
})
|
||||
.refine((h) => (Date.parse(h.endDate) - Date.parse(h.startDate)) / 86_400_000 < 31, {
|
||||
message: 'At most 31 days',
|
||||
path: ['endDate'],
|
||||
});
|
||||
export type HolidayInput = z.input<typeof HolidaySchema>;
|
||||
export type HolidayDto = z.output<typeof HolidaySchema> & { id: string };
|
||||
|
||||
/** Day of week and "HH:MM" of `at` in `timezone`. */
|
||||
export function localTime(at: Date, timezone: string): { day: Day; time: string; date: string } {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
})
|
||||
.formatToParts(at)
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
return {
|
||||
day: parts.weekday!.toLowerCase().slice(0, 3) as Day,
|
||||
time: `${parts.hour}:${parts.minute}`,
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** True when `date` (YYYY-MM-DD) falls on the holiday (recurring ones ignore the year). */
|
||||
export function isHoliday(
|
||||
h: Pick<HolidayDto, 'startDate' | 'endDate' | 'recurringYearly'>,
|
||||
date: string,
|
||||
) {
|
||||
if (!h.recurringYearly) return h.startDate <= date && date <= h.endDate;
|
||||
const md = date.slice(5);
|
||||
const [s, e] = [h.startDate.slice(5), h.endDate.slice(5)];
|
||||
return s <= e ? s <= md && md <= e : md >= s || md <= e; // a range over New Year
|
||||
}
|
||||
|
||||
/** The same decision the dialplan makes (03 §6.10): override, then holidays, then hours. */
|
||||
export function timeConditionStatus(
|
||||
tc: Pick<TimeConditionDto, 'timezone' | 'openRanges' | 'useHolidays' | 'overrideState'>,
|
||||
holidays: Pick<HolidayDto, 'startDate' | 'endDate' | 'recurringYearly'>[],
|
||||
at = new Date(),
|
||||
): TimeConditionStatus {
|
||||
if (tc.overrideState) return { now: tc.overrideState, reason: 'override' };
|
||||
const { day, time, date } = localTime(at, tc.timezone);
|
||||
if (tc.useHolidays && holidays.some((h) => isHoliday(h, date)))
|
||||
return { now: 'holiday', reason: 'holiday' };
|
||||
const open = tc.openRanges.some((r) => r.days.includes(day) && r.start <= time && time < r.end);
|
||||
return { now: open ? 'open' : 'closed', reason: 'hours' };
|
||||
}
|
||||
|
||||
/** The weekly grid edits half-hour slots: slots[day][0..47], slot i = i*30 min. */
|
||||
export const SLOTS = 48;
|
||||
const toSlot = (t: string) => {
|
||||
const [h, m] = t.split(':').map(Number) as [number, number];
|
||||
return (h * 60 + m) / 30;
|
||||
};
|
||||
const ofSlot = (i: number) =>
|
||||
`${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 ? '30' : '00'}`;
|
||||
|
||||
/** Ranges → grid. Times between half hours are rounded to the slot they start in. */
|
||||
export function rangesToSlots(ranges: OpenRange[]): Record<Day, boolean[]> {
|
||||
const out = Object.fromEntries(DAYS.map((d) => [d, Array<boolean>(SLOTS).fill(false)])) as Record<
|
||||
Day,
|
||||
boolean[]
|
||||
>;
|
||||
for (const r of ranges)
|
||||
for (const d of r.days)
|
||||
for (let i = Math.floor(toSlot(r.start)); i < Math.ceil(toSlot(r.end)); i++) out[d][i] = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Grid → ranges: one range per run of open slots, days with the same run grouped together. */
|
||||
export function slotsToRanges(slots: Record<Day, boolean[]>): OpenRange[] {
|
||||
const byRun = new Map<string, Day[]>();
|
||||
for (const d of DAYS) {
|
||||
for (let i = 0; i < SLOTS;) {
|
||||
if (!slots[d][i]) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
let j = i;
|
||||
while (j < SLOTS && slots[d][j]) j++;
|
||||
const key = `${ofSlot(i)}-${ofSlot(j)}`;
|
||||
byRun.set(key, [...(byRun.get(key) ?? []), d]);
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
return [...byRun.entries()]
|
||||
.map(([run, days]) => {
|
||||
const [start, end] = run.split('-') as [string, string];
|
||||
return { days, start, end };
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
DAYS.indexOf(a.days[0]!) - DAYS.indexOf(b.days[0]!) || a.start.localeCompare(b.start),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user