- 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>
151 lines
4.1 KiB
JavaScript
151 lines
4.1 KiB
JavaScript
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,
|
|
};
|
|
};
|