fix: Consider business hours when computing SLA breaches (#13392)

- Fixes SLA breach computation to respect the "Only during business
hours" setting
- Backend now pre-computes SLA deadlines, simplifying frontend logic

## How it works

Before: SLA deadlines were calculated using wall-clock time, ignoring
business hours.

After: When an SLA policy has "Only during business hours" enabled and
the inbox has working hours configured, the deadline is calculated by
adding threshold time only during business hours.


**How you check if a conversation has a SLA hit or miss?**

<img width="474" height="510" alt="Screenshot 2026-01-28 at 7 06 53 PM"
src="https://github.com/user-attachments/assets/54ec8581-18b8-45c6-a356-de8c778ea78d"
/>


**Example:**
- Conversation created: Friday 4:30 PM
- FRT threshold: 1 hour
- Business hours: Mon-Fri 9 AM - 5 PM

| | Breach time |
|--|--|
| Before | Friday 5:30 PM |
| After | Monday 9:30 AM |

## Test plan

- [x] Create an SLA policy with "Only during business hours" enabled
- [x] Configure inbox with business hours (e.g., Mon-Fri 9-5)
- [x] Conversation created during business hours
  - Create a conversation on Wednesday 10:00 AM UTC
- Expected: FRT deadline shows Wednesday 12:00 PM UTC (2 business hours
later)
- [x] Conversation created before business hours
  - Create a conversation on Wednesday 7:00 AM UTC
- Expected: FRT deadline shows Wednesday 11:00 AM UTC (counting starts
at 9 AM)
- [x] Conversation created after business hours
  - Create a conversation on Wednesday 6:00 PM UTC
- Expected: FRT deadline shows Thursday 11:00 AM UTC (counting starts
next day 9 AM)
- [x] Conversation created on weekend
  - Create a conversation on Saturday 10:00 AM UTC
  - Expected: FRT deadline shows Monday 11:00 AM UTC (skips weekend)
- [x] Threshold spans weekend
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
- Expected: FRT deadline shows Monday 10:00 AM UTC (1h Friday + 1h
Monday)
- [x] SLA without business hours
  - Create an SLA policy with only_during_business_hours: false
  - Create a conversation on Friday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Friday 6:00 PM UTC (wall-clock time)
- [x] All Day marked as closed_all_day
  - Create a conversation on Tuesday 4:00 PM UTC with 2-hour FRT
  - Expected: FRT deadline shows Thursday 10:00 AM UTC
- [x] All Day marked as open_all_day
  - Create a conversation on Saturday 10:00 AM UTC with 2-hour FRT
  - Expected: FRT deadline shows Saturday 12:00 PM UTC 
- [x] UI displays correct countdown
  - Verify conversation card shows correct SLA timer
  - Verify timer shows flame icon when breached
  - Verify timer shows alarm icon when within threshold
  - Time updates automatically when time passes
- [x] Verify the breach with a different timezone than your local
timezone

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
Pranav
2026-07-02 13:33:22 +05:30
committed by GitHub
co-authored by Muhsin Keloth Sojan Jose Sony Mathew Sony Mathew
parent 926a9d8a69
commit 49b0ab0e1f
13 changed files with 1228 additions and 100 deletions
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
const props = defineProps({
conversation: {
@@ -19,16 +19,8 @@ const slaStatus = ref({
icon: null,
});
// TODO: Remove this once we update the helper from utils
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
const convertObjectCamelCaseToSnakeCase = object => {
return Object.keys(object).reduce((acc, key) => {
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
return acc;
}, {});
};
const appliedSLA = computed(() => props.conversation?.appliedSla);
const slaEvents = computed(() => props.conversation?.slaEvents);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const hasSlaThreshold = computed(() => {
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
appliedSla: appliedSLA.value || {},
chat: props.conversation,
slaEvents: slaEvents.value || [],
});
};
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
@@ -27,6 +27,7 @@ defineOptions({
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -1,7 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
import SLAPopoverCard from './SLAPopoverCard.vue';
const props = defineProps({
@@ -61,6 +61,7 @@ const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value,
chat: props.chat,
slaEvents: slaEvents.value || [],
});
};
@@ -0,0 +1,150 @@
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
/**
* Formats seconds into a human-readable time string
* @param {number} seconds - The time in seconds (can be negative for overdue)
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
*/
const formatSLATime = seconds => {
const absSeconds = Math.abs(seconds);
const units = {
y: 31536000,
mo: 2592000,
d: 86400,
h: 3600,
m: 60,
};
if (absSeconds < 60) {
return '1m';
}
const parts = [];
let remaining = absSeconds;
Object.entries(units).forEach(([unit, value]) => {
if (parts.length >= 2) return;
const count = Math.floor(remaining / value);
if (count > 0) {
parts.push(`${count}${unit}`);
remaining -= count * value;
}
});
return parts.join(' ');
};
const toUnixTimestamp = value => {
if (!value || typeof value === 'number') return value;
const numericValue = Number(value);
if (!Number.isNaN(numericValue)) return numericValue;
const parsedTimestamp = Date.parse(value);
return Number.isNaN(parsedTimestamp)
? value
: Math.floor(parsedTimestamp / 1000);
};
/**
* Evaluates SLA status using backend-computed due times
* @param {Object} params - Parameters object
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
* @param {Object} params.chat - The conversation object
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
*/
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
if (!appliedSla || !chat) {
return emptyStatus;
}
const sla = useCamelCase(appliedSla);
const conversation = useCamelCase(chat);
const events = useCamelCase(slaEvents || []);
const currentTime = Math.floor(Date.now() / 1000);
const slaStatuses = [];
const dueAtByType = {
FRT: sla.slaFrtDueAt,
RT: sla.slaRtDueAt,
};
const slaTypes = ['FRT', 'NRT', 'RT'];
events.forEach(event => {
const type = event.eventType?.toUpperCase();
if (!slaTypes.includes(type)) return;
const missedAt =
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
if (!missedAt) return;
slaStatuses.push({
type,
threshold: missedAt - currentTime,
icon: 'flame',
isSlaMissed: true,
});
});
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
const shouldCheckFirstResponse =
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
// Check FRT - until first reply is made on time
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
const threshold = sla.slaFrtDueAt - currentTime;
slaStatuses.push({
type: 'FRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check NRT - only if first reply made and waiting for response
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
const threshold = sla.slaNrtDueAt - currentTime;
slaStatuses.push({
type: 'NRT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
// Check RT - only if conversation is unresolved
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
const threshold = sla.slaRtDueAt - currentTime;
slaStatuses.push({
type: 'RT',
threshold,
icon: threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: threshold <= 0,
});
}
if (slaStatuses.length === 0) {
return emptyStatus;
}
// Show existing breaches before upcoming deadlines, then pick the closest timer.
slaStatuses.sort((a, b) => {
if (a.isSlaMissed !== b.isSlaMissed) {
return a.isSlaMissed ? -1 : 1;
}
return Math.abs(a.threshold) - Math.abs(b.threshold);
});
const mostUrgent = slaStatuses[0];
return {
type: mostUrgent.type,
threshold: formatSLATime(mostUrgent.threshold),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
};
};
@@ -0,0 +1,450 @@
import { evaluateSLAStatus } from '../slaHelper';
describe('#SLA Helpers', () => {
const currentTimestamp = 1700000000; // Fixed timestamp for testing
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(currentTimestamp * 1000);
});
afterEach(() => {
vi.useRealTimers();
});
describe('evaluateSLAStatus', () => {
describe('when inputs are invalid', () => {
it('returns empty status when appliedSla is null', () => {
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty status when chat is null', () => {
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
describe('FRT (First Response Time)', () => {
it('returns FRT status when first reply not made and within threshold', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed FRT status when threshold is exceeded', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return FRT when first reply was made before due time', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 1000,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('FRT');
});
it('does not return FRT when first reply was made at due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
});
it('returns missed FRT when first reply was made after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 300,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 600,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: new Date(
(currentTimestamp - 300) * 1000
).toISOString(),
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('10m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed FRT event created after the deadline', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
const chat = { first_reply_created_at: null, status: 'open' };
const slaEvents = [
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
});
describe('NRT (Next Response Time)', () => {
it('returns NRT status when waiting for response and within threshold', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it('returns missed NRT status when threshold is exceeded', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2700,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when not waiting for response', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
it('returns missed NRT when a recorded NRT miss exists after response', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: null,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('15m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp + 300,
sla_rt_due_at: currentTimestamp + 1800,
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 60,
status: 'open',
};
const slaEvents = [
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('NRT');
expect(result.threshold).toBe('30m');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return NRT when first reply not made', () => {
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).not.toBe('NRT');
});
});
describe('RT (Resolution Time)', () => {
it('returns RT status when conversation is open and within threshold', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
});
it.each(['pending', 'snoozed'])(
'returns RT status when conversation is %s and within threshold',
status => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status,
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('2h');
expect(result.icon).toBe('alarm');
expect(result.isSlaMissed).toBe(false);
}
);
it('returns missed RT status when threshold is exceeded', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('uses the due time for a missed RT event created after the deadline', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
const chat = {
first_reply_created_at: currentTimestamp - 7200,
status: 'open',
};
const slaEvents = [
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
];
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
expect(result.icon).toBe('flame');
expect(result.isSlaMissed).toBe(true);
});
it('does not return RT when conversation is resolved', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
const chat = {
first_reply_created_at: currentTimestamp - 3600,
status: 'resolved',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('');
});
});
describe('priority selection', () => {
it('returns most urgent SLA when multiple are active', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
sla_rt_due_at: currentTimestamp + 3600, // 1h
};
const chat = {
first_reply_created_at: null,
waiting_since: currentTimestamp - 600,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
// FRT is selected because first_reply_created_at is null
// NRT is not checked when first_reply_created_at is null
expect(result.type).toBe('RT');
expect(result.threshold).toBe('1h');
});
it('returns most urgent missed SLA over upcoming SLA', () => {
const appliedSla = {
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
};
const chat = {
first_reply_created_at: currentTimestamp - 7200,
waiting_since: currentTimestamp - 2100,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('NRT');
expect(result.isSlaMissed).toBe(true);
});
it('returns an existing missed SLA over a closer upcoming SLA', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
};
const chat = {
first_reply_created_at: null,
status: 'open',
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.type).toBe('FRT');
expect(result.threshold).toBe('2h');
expect(result.isSlaMissed).toBe(true);
});
});
describe('time formatting', () => {
it('formats time in days and hours', () => {
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1d 1h');
});
it('formats time less than a minute as 1m', () => {
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
const chat = { first_reply_created_at: null, status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1m');
});
it('formats months correctly', () => {
const appliedSla = {
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
}; // 1 month + 1 day
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result.threshold).toBe('1mo 1d');
});
});
describe('empty status scenarios', () => {
it('returns empty when no SLA thresholds are set', () => {
const appliedSla = {};
const chat = { status: 'open' };
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
it('returns empty when all conditions are met', () => {
const appliedSla = {
sla_frt_due_at: currentTimestamp + 3600,
sla_nrt_due_at: currentTimestamp + 1800,
sla_rt_due_at: currentTimestamp + 7200,
};
const chat = {
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
waiting_since: null, // Not waiting, so NRT not applicable
status: 'resolved', // RT not applicable
};
const result = evaluateSLAStatus({ appliedSla, chat });
expect(result).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
});
});
});