/opt/mawid/apps/api/src/agent
NameSizeModeActions
agent.e2e.spec.ts132960644editdlrm
agent.module.ts8320644editdlrm
agent.service.ts81630644editdlrm
availability.spec.ts84820644editdlrm
availability.ts51830644editdlrm
booking-tools.service.ts144400644editdlrm
openai.client.ts22470644editdlrm
system-prompt.ts18550644editdlrm
tool-definitions.ts36140644editdlrm
tz.ts28820644editdlrm
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); }); });