/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/availability.spec.ts (8482B)
import { computeAvailableSlots, isSlotAvailable, type AvailabilityQuery } from './availability';
import { wallTimeToUtc, utcToWallTime } from './tz';
const TZ = 'Europe/Istanbul'; // UTC+3, no DST
const staffA = {
id: 'staff-a',
name: 'Dr. A',
workingHours: { wed: [{ start: '09:00', end: '12:00' }] },
};
function base(overrides: Partial
= {}): AvailabilityQuery {
return {
timezone: TZ,
durationMinutes: 30,
staff: [staffA],
busy: [],
// Wed 2026-07-15 local full day
from: wallTimeToUtc(TZ, 2026, 7, 15, 0, 0),
to: wallTimeToUtc(TZ, 2026, 7, 16, 0, 0),
granularityMinutes: 30,
...overrides,
};
}
const localLabel = (d: Date) => {
const w = utcToWallTime(TZ, d);
return `${String(w.hour).padStart(2, '0')}:${String(w.minute).padStart(2, '0')}`;
};
describe('computeAvailableSlots', () => {
it('generates slots on the working-hours grid in clinic-local time', () => {
const slots = computeAvailableSlots(base());
expect(slots.map((s) => localLabel(s.startsAt))).toEqual([
'09:00',
'09:30',
'10:00',
'10:30',
'11:00',
'11:30',
]);
// 09:00 local == 06:00 UTC (Istanbul is UTC+3)
expect(slots[0].startsAt.toISOString()).toBe('2026-07-15T06:00:00.000Z');
});
it('returns nothing on closed days', () => {
const slots = computeAvailableSlots(
base({
// Thursday — staff only works Wednesdays
from: wallTimeToUtc(TZ, 2026, 7, 16, 0, 0),
to: wallTimeToUtc(TZ, 2026, 7, 17, 0, 0),
}),
);
expect(slots).toEqual([]);
});
it('a slot must fit entirely before the interval end', () => {
const slots = computeAvailableSlots(base({ durationMinutes: 45 }));
// last 45' appointment that fits before 12:00 starts at 11:00 (grid 30')
expect(localLabel(slots[slots.length - 1].startsAt)).toBe('11:00');
});
it('respects breaks modeled as multiple intervals', () => {
const slots = computeAvailableSlots(
base({
staff: [
{
...staffA,
workingHours: {
wed: [
{ start: '09:00', end: '10:00' },
{ start: '13:00', end: '14:00' },
],
},
},
],
}),
);
expect(slots.map((s) => localLabel(s.startsAt))).toEqual(['09:00', '09:30', '13:00', '13:30']);
});
it('removes slots overlapping existing appointments, including partial overlaps', () => {
const slots = computeAvailableSlots(
base({
busy: [
{
staffId: 'staff-a',
// 09:45–10:15 local — clips 09:30 and 10:00 slots
startsAt: wallTimeToUtc(TZ, 2026, 7, 15, 9, 45),
endsAt: wallTimeToUtc(TZ, 2026, 7, 15, 10, 15),
},
],
}),
);
expect(slots.map((s) => localLabel(s.startsAt))).toEqual(['09:00', '10:30', '11:00', '11:30']);
});
it('back-to-back appointments do not block adjacent slots', () => {
const slots = computeAvailableSlots(
base({
busy: [
{
staffId: 'staff-a',
startsAt: wallTimeToUtc(TZ, 2026, 7, 15, 9, 30),
endsAt: wallTimeToUtc(TZ, 2026, 7, 15, 10, 0),
},
],
}),
);
// 09:00 (ends exactly at busy start) and 10:00 (starts exactly at busy end) both free
expect(slots.map((s) => localLabel(s.startsAt))).toEqual(['09:00', '10:00', '10:30', '11:00', '11:30']);
});
it("one staff member's appointments do not block another's slots", () => {
const staffB = { ...staffA, id: 'staff-b', name: 'Dr. B' };
const slots = computeAvailableSlots(
base({
staff: [staffA, staffB],
busy: [
{
staffId: 'staff-a',
startsAt: wallTimeToUtc(TZ, 2026, 7, 15, 9, 0),
endsAt: wallTimeToUtc(TZ, 2026, 7, 15, 12, 0),
},
],
}),
);
expect(slots.every((s) => s.staffId === 'staff-b')).toBe(true);
expect(slots).toHaveLength(6);
});
it('staff without own hours fall back to clinic hours', () => {
const slots = computeAvailableSlots(
base({
staff: [{ id: 'staff-c', name: 'Dr. C', workingHours: {} }],
clinicHours: { wed: [{ start: '10:00', end: '11:00' }] },
}),
);
expect(slots.map((s) => localLabel(s.startsAt))).toEqual(['10:00', '10:30']);
});
it('clamps to the [from, to] query bounds', () => {
const slots = computeAvailableSlots(
base({
from: wallTimeToUtc(TZ, 2026, 7, 15, 10, 0),
to: wallTimeToUtc(TZ, 2026, 7, 15, 11, 30),
}),
);
expect(slots.map((s) => localLabel(s.startsAt))).toEqual(['10:00', '10:30', '11:00']);
});
it('spans multiple days without duplicating any', () => {
const slots = computeAvailableSlots(
base({
staff: [
{
...staffA,
workingHours: {
wed: [{ start: '09:00', end: '10:00' }],
thu: [{ start: '09:00', end: '10:00' }],
},
},
],
from: wallTimeToUtc(TZ, 2026, 7, 15, 0, 0),
to: wallTimeToUtc(TZ, 2026, 7, 17, 0, 0),
}),
);
expect(slots).toHaveLength(4);
const starts = new Set(slots.map((s) => s.startsAt.toISOString()));
expect(starts.size).toBe(4);
});
it('handles timezones with DST: normal Berlin day converts correctly', () => {
const berlin = 'Europe/Berlin'; // UTC+2 in July (CEST)
const slots = computeAvailableSlots(
base({
timezone: berlin,
from: wallTimeToUtc(berlin, 2026, 7, 15, 0, 0),
to: wallTimeToUtc(berlin, 2026, 7, 16, 0, 0),
}),
);
expect(slots[0].startsAt.toISOString()).toBe('2026-07-15T07:00:00.000Z'); // 09:00 CEST
});
it('DST spring-forward day (Berlin 2026-03-29, 02:00→03:00) drops nonexistent times', () => {
const berlin = 'Europe/Berlin';
const slots = computeAvailableSlots(
base({
timezone: berlin,
staff: [
{
id: 'night',
name: 'Night Shift',
// 01:00–04:00 local on the transition night — 02:00-03:00 does not exist
workingHours: { sun: [{ start: '01:00', end: '04:00' }] },
},
],
from: wallTimeToUtc(berlin, 2026, 3, 29, 0, 0),
to: wallTimeToUtc(berlin, 2026, 3, 29, 12, 0),
durationMinutes: 30,
granularityMinutes: 30,
}),
);
const isoStarts = slots.map((s) => s.startsAt.toISOString());
expect(new Set(isoStarts).size).toBe(isoStarts.length); // no duplicate instants
// The wall-clock window is 3h but only 2h of real time exists that night.
expect(slots.length).toBe(4);
});
it('DST fall-back day (Berlin 2026-10-25) produces no duplicate slot instants', () => {
const berlin = 'Europe/Berlin';
const slots = computeAvailableSlots(
base({
timezone: berlin,
staff: [
{ id: 's', name: 'S', workingHours: { sun: [{ start: '01:00', end: '04:00' }] } },
],
from: wallTimeToUtc(berlin, 2026, 10, 25, 0, 0),
to: wallTimeToUtc(berlin, 2026, 10, 25, 12, 0),
}),
);
const isoStarts = slots.map((s) => s.startsAt.toISOString());
expect(new Set(isoStarts).size).toBe(isoStarts.length);
});
it('returns empty for degenerate inputs', () => {
expect(computeAvailableSlots(base({ durationMinutes: 0 }))).toEqual([]);
expect(computeAvailableSlots(base({ from: new Date(9), to: new Date(1) }))).toEqual([]);
expect(computeAvailableSlots(base({ staff: [] }))).toEqual([]);
});
it('ignores malformed HH:MM strings instead of throwing', () => {
const slots = computeAvailableSlots(
base({
staff: [{ id: 'x', name: 'X', workingHours: { wed: [{ start: '9am', end: 'noon' }] } }],
}),
);
expect(slots).toEqual([]);
});
});
describe('isSlotAvailable', () => {
it('accepts an exact grid slot and rejects off-grid or taken ones', () => {
const q = base();
const start = wallTimeToUtc(TZ, 2026, 7, 15, 9, 30);
const end = wallTimeToUtc(TZ, 2026, 7, 15, 10, 0);
expect(isSlotAvailable(q, 'staff-a', start, end)).toBe(true);
expect(isSlotAvailable(q, 'staff-b', start, end)).toBe(false);
expect(
isSlotAvailable(
{ ...q, busy: [{ staffId: 'staff-a', startsAt: start, endsAt: end }] },
'staff-a',
start,
end,
),
).toBe(false);
});
});