From e4f9355352a238bc1944ea00befb0a15c58e329f Mon Sep 17 00:00:00 2001 From: Muhsin Date: Thu, 12 Mar 2026 15:07:40 +0400 Subject: [PATCH] feat: add call duration --- .../message/bubbles/VoiceCall.spec.js | 11 ++++++++ .../message/bubbles/VoiceCall.vue | 9 ++++++- .../dashboard/composables/useCallSession.js | 5 ++-- .../shared/helpers/specs/timeHelper.spec.js | 19 ++++++++++++++ app/javascript/shared/helpers/timeHelper.js | 26 +++++++++++++++++++ .../app/services/voice/call_status/manager.rb | 9 ++++++- .../voice/status_update_service_spec.rb | 7 +++-- 7 files changed, 79 insertions(+), 7 deletions(-) diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js index 453a0554b..fb8989e55 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.spec.js @@ -77,4 +77,15 @@ describe('VoiceCall.vue', () => { expect(wrapper.text()).toContain('You answered'); }); + + it('shows the formatted duration when a call has ended', () => { + messageContext.contentAttributes.value.data.status = + VOICE_CALL_STATUS.COMPLETED; + messageContext.contentAttributes.value.data.meta.duration = 133; + + const wrapper = mountComponent(); + + expect(wrapper.text()).toContain('Call ended'); + expect(wrapper.text()).toContain('02:13'); + }); }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 170f66474..fbd7bfd69 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -3,6 +3,7 @@ import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { useMessageContext } from '../provider.js'; import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants'; +import { formatDuration } from 'shared/helpers/timeHelper'; import Icon from 'dashboard/components-next/icon/Icon.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; @@ -29,6 +30,9 @@ const status = computed(() => data.value?.status?.toString()); const joinedBy = computed(() => { return data.value?.meta?.joinedBy || data.value?.meta?.joined_by; }); +const callDuration = computed(() => { + return data.value?.meta?.duration; +}); const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING); const isFailed = computed(() => @@ -64,7 +68,10 @@ const subtext = computed(() => { } if (status.value === VOICE_CALL_STATUS.COMPLETED) { - return t('CONVERSATION.VOICE_CALL.CALL_ENDED'); + return ( + formatDuration(callDuration.value) || + t('CONVERSATION.VOICE_CALL.CALL_ENDED') + ); } if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js index 380164626..df3d40183 100644 --- a/app/javascript/dashboard/composables/useCallSession.js +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -5,6 +5,7 @@ import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient'; import { useAlert } from 'dashboard/composables'; import { useCallsStore } from 'dashboard/stores/calls'; import Timer from 'dashboard/helper/Timer'; +import { formatDuration } from 'shared/helpers/timeHelper'; export function useCallSession() { const callsStore = useCallsStore(); @@ -98,9 +99,7 @@ export function useCallSession() { }; const formattedCallDuration = computed(() => { - const minutes = Math.floor(callDuration.value / 60); - const seconds = callDuration.value % 60; - return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + return formatDuration(callDuration.value); }); return { diff --git a/app/javascript/shared/helpers/specs/timeHelper.spec.js b/app/javascript/shared/helpers/specs/timeHelper.spec.js index e7a4e025f..4e03507ea 100644 --- a/app/javascript/shared/helpers/specs/timeHelper.spec.js +++ b/app/javascript/shared/helpers/specs/timeHelper.spec.js @@ -4,6 +4,7 @@ import { dynamicTime, dateFormat, shortTimestamp, + formatDuration, getDayDifferenceFromNow, hasOneDayPassed, } from 'shared/helpers/timeHelper'; @@ -93,6 +94,24 @@ describe('#shortTimestamp', () => { }); }); +describe('#formatDuration', () => { + it('returns empty string for invalid values', () => { + expect(formatDuration(null)).toEqual(''); + expect(formatDuration(undefined)).toEqual(''); + expect(formatDuration(-1)).toEqual(''); + expect(formatDuration('abc')).toEqual(''); + }); + + it('formats short durations as mm:ss', () => { + expect(formatDuration(0)).toEqual('00:00'); + expect(formatDuration(133)).toEqual('02:13'); + }); + + it('formats long durations as hh:mm:ss', () => { + expect(formatDuration(3733)).toEqual('01:02:13'); + }); +}); + describe('#getDayDifferenceFromNow', () => { it('returns 0 for timestamps from today', () => { // Mock current date: May 5, 2023 diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js index 5347d2410..0fb57e3dd 100644 --- a/app/javascript/shared/helpers/timeHelper.js +++ b/app/javascript/shared/helpers/timeHelper.js @@ -93,6 +93,32 @@ export const shortTimestamp = (time, withAgo = false) => { return convertToShortTime; }; +/** + * Formats a duration in seconds into mm:ss or hh:mm:ss. + * @param {number|string} durationInSeconds - Duration in seconds. + * @returns {string} Formatted duration string. + */ +export const formatDuration = durationInSeconds => { + if (durationInSeconds === null || durationInSeconds === undefined) return ''; + + const totalSeconds = Number(durationInSeconds); + if (Number.isNaN(totalSeconds) || totalSeconds < 0) return ''; + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours.toString().padStart(2, '0')}:${minutes + .toString() + .padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + } + + return `${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; +}; + /** * Calculates the difference in days between now and a given timestamp. * @param {Date} now - Current date/time. diff --git a/enterprise/app/services/voice/call_status/manager.rb b/enterprise/app/services/voice/call_status/manager.rb index 82d7efde7..276d129f5 100644 --- a/enterprise/app/services/voice/call_status/manager.rb +++ b/enterprise/app/services/voice/call_status/manager.rb @@ -49,9 +49,16 @@ class Voice::CallStatus::Manager .first return unless message - data = (message.content_attributes || {}).dup + data = (message.content_attributes || {}).deep_dup data['data'] ||= {} data['data']['status'] = status + data['data']['meta'] ||= {} + + if status == 'completed' && conversation.additional_attributes['call_duration'].present? + data['data']['meta']['duration'] = conversation.additional_attributes['call_duration'] + else + data['data']['meta'].delete('duration') + end message.update!(content_attributes: data) end diff --git a/spec/enterprise/services/voice/status_update_service_spec.rb b/spec/enterprise/services/voice/status_update_service_spec.rb index fab626fb6..2366ad17d 100644 --- a/spec/enterprise/services/voice/status_update_service_spec.rb +++ b/spec/enterprise/services/voice/status_update_service_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Voice::StatusUpdateService do .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(16)}")) end - it 'updates conversation and last voice message with call status' do + it 'updates conversation and last voice message with call status and duration' do # Ensure records are created after stub setup conversation message @@ -45,14 +45,17 @@ RSpec.describe Voice::StatusUpdateService do described_class.new( account: account, call_sid: call_sid, - call_status: 'completed' + call_status: 'completed', + payload: { 'CallDuration' => '133' } ).perform conversation.reload message.reload expect(conversation.additional_attributes['call_status']).to eq('completed') + expect(conversation.additional_attributes['call_duration']).to eq(133) expect(message.content_attributes.dig('data', 'status')).to eq('completed') + expect(message.content_attributes.dig('data', 'meta', 'duration')).to eq(133) end it 'normalizes busy to no-answer' do